Updating State
Overview
Section titled “Overview”Phaser Hooks provides two primary methods for updating state: .set() and .patch(). Understanding the difference between these methods is crucial for efficient state management.
The .set() Method
Section titled “The .set() Method”The .set() method completely replaces the current state with a new value.
Basic Usage
Section titled “Basic Usage”import { withLocalState } from 'phaser-hooks';
export class GameScene extends Phaser.Scene { create() { const player = withLocalState(this, 'player', { hp: 100, mp: 50, level: 1, position: { x: 0, y: 0 }, });
// Replaces the ENTIRE state player.set({ hp: 90, mp: 50, level: 1, position: { x: 10, y: 20 }, });
console.log(player.get()); // { hp: 90, mp: 50, level: 1, position: { x: 10, y: 20 } } }}Direct Value
Section titled “Direct Value”Pass the new state value directly:
const counter = withLocalState(this, 'counter', 0);
// Direct valuecounter.set(10);console.log(counter.get()); // 10
counter.set(counter.get() + 1);console.log(counter.get()); // 11Function Form
Section titled “Function Form”Pass a function that receives the current state and returns the new state:
const counter = withLocalState(this, 'counter', 0);
// Function form - receives current statecounter.set((currentState) => currentState + 1);console.log(counter.get()); // 1
counter.set((currentState) => currentState * 2);console.log(counter.get()); // 2Avoiding Race Conditions
Section titled “Avoiding Race Conditions”When updating state in async operations, always use the function form:
export class GameScene extends Phaser.Scene { async create() { const score = withLocalState(this, 'score', 0);
const scoreValue = score.get(); // ❌ BAD - Race condition risk const fetchBonus = async () => { await this.time.delayedCall(1000, () => {}); score.set(scoreValue + 10); // scoreValue might be stale! };
// ✅ GOOD - Always uses current state const fetchBonusSafe = async () => { await this.time.delayedCall(1000, () => {}); score.set((current) => current + 10); // Always accurate };
// If these run in parallel, the bad version can lose updates fetchBonus(); fetchBonus();
// The safe version always works correctly fetchBonusSafe(); fetchBonusSafe(); }}With Objects - Manual Merge
Section titled “With Objects - Manual Merge”When working with objects, you need to manually merge properties:
const player = withLocalState(this, 'player', { hp: 100, mp: 50, level: 1,});
// ❌ This REPLACES everything - loses mp and level!player.set({ hp: 90 });console.log(player.get()); // { hp: 90 } - mp and level are gone!
// ✅ Use spread operator to preserve other propertiesplayer.set({ ...player.get(), hp: 90,});console.log(player.get()); // { hp: 90, mp: 50, level: 1 }
// ✅ Or use function formplayer.set((current) => ({ ...current, hp: 90,}));The .patch() Method
Section titled “The .patch() Method”The .patch() method performs a deep merge, updating only the keys you specify while preserving everything else.
Basic Usage
Section titled “Basic Usage”import { withLocalState } from 'phaser-hooks';
export class GameScene extends Phaser.Scene { create() { const player = withLocalState(this, 'player', { hp: 100, mp: 50, level: 1, position: { x: 0, y: 0 }, });
// Only updates hp - everything else is preserved! player.patch({ hp: 90 });
console.log(player.get()); // { hp: 90, mp: 50, level: 1, position: { x: 0, y: 0 } } }}Deep Merge Behavior
Section titled “Deep Merge Behavior”.patch() performs a deep merge, merging nested objects recursively:
const player = withLocalState(this, 'player', { name: 'Hero', stats: { hp: 100, mp: 50, strength: 10, }, inventory: { gold: 100, items: ['sword', 'shield'], },});
// Deep merge - only updates nested properties you specifyplayer.patch({ stats: { hp: 90, // Updates hp // mp and strength are preserved },});
console.log(player.get());// {// name: 'Hero',// stats: { hp: 90, mp: 50, strength: 10 },// inventory: { gold: 100, items: ['sword', 'shield'] }// }Direct Value
Section titled “Direct Value”Pass the partial object directly:
const player = withLocalState(this, 'player', { hp: 100, mp: 50, level: 1,});
// Direct partial updateplayer.patch({ hp: 90 });console.log(player.get()); // { hp: 90, mp: 50, level: 1 }
player.patch({ mp: 40, level: 2 });console.log(player.get()); // { hp: 90, mp: 40, level: 2 }Function Form
Section titled “Function Form”Pass a function that receives the current state and returns the partial update:
const player = withLocalState(this, 'player', { hp: 100, mp: 50, level: 1,});
// Function form - receives current stateplayer.patch((current) => ({ hp: current.hp - 10,}));
console.log(player.get()); // { hp: 90, mp: 50, level: 1 }
// Calculate based on current stateplayer.patch((current) => ({ mp: current.mp + current.level * 5,}));
console.log(player.get()); // { hp: 90, mp: 55, level: 1 }Avoiding Race Conditions with .patch()
Section titled “Avoiding Race Conditions with .patch()”Just like .set(), use the function form in async operations:
export class GameScene extends Phaser.Scene { async create() { const player = withLocalState(this, 'player', { hp: 100, mp: 50, gold: 0, });
const goldValue = player.get().gold; // ❌ BAD - Race condition risk const earnGold = async (amount: number) => { await this.fetchReward(); player.patch({ gold: goldValue + amount }); // Might be stale };
// ✅ GOOD - Always uses current state const earnGoldSafe = async (amount: number) => { await this.fetchReward(); player.patch((current) => ({ gold: current.gold + amount })); };
// Parallel calls are safe with function form earnGoldSafe(10); earnGoldSafe(20); earnGoldSafe(15); // Will correctly add 45 gold total }
async fetchReward() { return new Promise((resolve) => { this.time.delayedCall(100, resolve); }); }}.set() vs .patch() Comparison
Section titled “.set() vs .patch() Comparison”Feature Comparison
Section titled “Feature Comparison”| Feature | .set() | .patch() |
|---|---|---|
| Behavior | Replaces entire state | Deep merges partial update |
| Works with primitives | ✅ Yes | ❌ No (objects only) |
| Works with objects | ✅ Yes | ✅ Yes |
| Requires all properties | ✅ Yes | ❌ No |
| Deep merge | ❌ No | ✅ Yes |
| Function form | ✅ Yes | ✅ Yes |
| Async-safe (function) | ✅ Yes | ✅ Yes |
When to Use Each
Section titled “When to Use Each”Use .set() when:
- Working with primitive values (numbers, strings, booleans)
- You want to replace the entire state
- You need complete control over the new state
- State structure might change completely
Use .patch() when:
- Working with objects
- You only want to update specific properties
- You want to preserve nested object properties
- You need convenient partial updates
Practical Examples
Section titled “Practical Examples”Example 1: Game Score (Primitive)
Section titled “Example 1: Game Score (Primitive)”With primitives, only .set() works:
export class GameScene extends Phaser.Scene { create() { const score = withLocalState(this, 'score', 0);
// ✅ Works - set() works with primitives score.set(100); score.set((current) => current + 50);
// ❌ Won't work - patch() doesn't work with primitives // score.patch(100); // Error!
console.log(score.get()); // 150 }}Example 2: Player Stats (Object)
Section titled “Example 2: Player Stats (Object)”With objects, both work, but .patch() is more convenient:
interface Player { hp: number; mp: number; level: number; exp: number;}
export class GameScene extends Phaser.Scene { create() { const player = withLocalState(this, 'player', { hp: 100, mp: 50, level: 1, exp: 0, });
// Using .set() - requires all properties player.set({ ...player.get(), hp: 90, });
// Using .patch() - simpler, only specify what changes player.patch({ hp: 90 });
// Both achieve the same result console.log(player.get()); // { hp: 90, mp: 50, level: 1, exp: 0 } }}Example 3: Complex Nested State
Section titled “Example 3: Complex Nested State”.patch() shines with nested objects:
interface GameState { player: { stats: { hp: number; mp: number }; position: { x: number; y: number }; inventory: string[]; }; settings: { audio: { music: number; sfx: number }; graphics: { quality: string }; };}
export class GameScene extends Phaser.Scene { create() { const game = withLocalState(this, 'game', { player: { stats: { hp: 100, mp: 50 }, position: { x: 0, y: 0 }, inventory: ['sword'], }, settings: { audio: { music: 0.8, sfx: 0.6 }, graphics: { quality: 'high' }, }, });
// With .set() - verbose and error-prone game.set({ ...game.get(), player: { ...game.get().player, stats: { ...game.get().player.stats, hp: 90, }, }, });
// With .patch() - clean and simple game.patch({ player: { stats: { hp: 90, }, }, });
// .patch() performs deep merge automatically! console.log(game.get().player.stats.mp); // 50 (preserved) console.log(game.get().settings); // Still intact }}Example 4: Async Operations
Section titled “Example 4: Async Operations”Both methods support function form for async safety:
export class GameScene extends Phaser.Scene { create() { const player = withLocalState(this, 'player', { hp: 100, gold: 0, kills: 0, });
// Async with .set() this.time.delayedCall(1000, () => { player.set((current) => ({ ...current, hp: current.hp - 10, })); });
// Async with .patch() - cleaner this.time.delayedCall(1000, () => { player.patch((current) => ({ hp: current.hp - 10, })); });
// Multiple async updates this.collectGold(10); this.collectGold(20); this.recordKill(); }
collectGold(amount: number) { const player = withLocalState(this, 'player', { hp: 100, gold: 0, kills: 0, });
// Function form prevents race conditions this.time.delayedCall(Math.random() * 1000, () => { player.patch((current) => ({ gold: current.gold + amount, })); }); }
recordKill() { const player = withLocalState(this, 'player', { hp: 100, gold: 0, kills: 0, });
player.patch((current) => ({ kills: current.kills + 1, })); }}Example 5: Incrementing Counters
Section titled “Example 5: Incrementing Counters”Common pattern for updating numeric properties in objects:
export class GameScene extends Phaser.Scene { create() { const stats = withLocalState(this, 'stats', { score: 0, kills: 0, deaths: 0, combo: 0, });
// Increment with .set() - verbose stats.set({ ...stats.get(), score: stats.get().score + 100, });
// Increment with .patch() - clean stats.patch((current) => ({ score: current.score + 100, }));
// Multiple increments this.events.on('enemy-killed', () => { stats.patch((current) => ({ kills: current.kills + 1, score: current.score + 50, combo: current.combo + 1, })); });
this.events.on('player-died', () => { stats.patch((current) => ({ deaths: current.deaths + 1, combo: 0, // Reset combo })); }); }}Best Practices
Section titled “Best Practices”1. Choose the Right Method
Section titled “1. Choose the Right Method”// ✅ Primitives - use .set()const counter = withLocalState(this, 'counter', 0);counter.set((current) => current + 1);
// ✅ Objects with many properties - use .patch()const player = withLocalState(this, 'player', { hp: 100, mp: 50, level: 1 });player.patch({ hp: 90 });
// ✅ Complete state replacement - use .set()const config = withLocalState(this, 'config', { theme: 'dark' });config.set({ theme: 'light', newFeature: true });2. Always Use Function Form in Async
Section titled “2. Always Use Function Form in Async”// ✅ ALWAYS use function form in async operationsthis.time.delayedCall(1000, () => { score.set((current) => current + 10);});
// ✅ ALWAYS use function form in event handlersthis.events.on('collect-coin', () => { player.patch((current) => ({ gold: current.gold + 1, }));});
// ❌ AVOID direct access in asyncthis.time.delayedCall(1000, () => { score.set(score.get() + 10); // Might be stale!});3. Use .patch() for Readability
Section titled “3. Use .patch() for Readability”When working with objects, .patch() is more readable:
// ❌ Harder to readplayer.set({ ...player.get(), hp: player.get().hp - damage, mp: player.get().mp - manaCost,});
// ✅ Clear and conciseplayer.patch((current) => ({ hp: current.hp - damage, mp: current.mp - manaCost,}));4. Type Safety
Section titled “4. Type Safety”TypeScript will help you catch errors:
interface Player { hp: number; mp: number; level: number;}
const player = withLocalState(this, 'player', { hp: 100, mp: 50, level: 1,});
// ✅ TypeScript validates .patch() propertiesplayer.patch({ hp: 90 }); // OKplayer.patch({ invalidProp: 10 }); // TypeScript error!
// ✅ TypeScript ensures .set() has all required propertiesplayer.set({ hp: 90, mp: 50, level: 1 }); // OKplayer.set({ hp: 90 }); // TypeScript error - missing properties!Common Mistakes
Section titled “Common Mistakes”Mistake 1: Using .patch() with Primitives
Section titled “Mistake 1: Using .patch() with Primitives”const counter = withLocalState(this, 'counter', 0);
// ❌ Won't work - .patch() requires objects// counter.patch(5); // Error!
// ✅ Use .set() insteadcounter.set(5);counter.set((current) => current + 1);Mistake 2: Forgetting to Spread with .set()
Section titled “Mistake 2: Forgetting to Spread with .set()”const player = withLocalState(this, 'player', { hp: 100, mp: 50, level: 1,});
// ❌ Loses mp and level!player.set({ hp: 90 });console.log(player.get()); // { hp: 90 } - other properties gone!
// ✅ Use spread or switch to .patch()player.set({ ...player.get(), hp: 90 });// orplayer.patch({ hp: 90 });Mistake 3: Not Using Function Form in Async
Section titled “Mistake 3: Not Using Function Form in Async”// ❌ Race condition riskthis.time.delayedCall(100, () => { score.set(score.get() + 10);});this.time.delayedCall(100, () => { score.set(score.get() + 20);});// Might lose one update!
// ✅ Safe with function formthis.time.delayedCall(100, () => { score.set((current) => current + 10);});this.time.delayedCall(100, () => { score.set((current) => current + 20);});// Both updates will apply correctlySummary
Section titled “Summary”.set() - Complete Replacement
Section titled “.set() - Complete Replacement”- Replaces entire state
- Works with any type (primitives, objects, arrays)
- Requires all properties for objects
- Use when you need complete control
// Direct valuestate.set(newValue);
// Function form (async-safe)state.set((current) => newValue);.patch() - Partial Update
Section titled “.patch() - Partial Update”- Performs deep merge
- Only works with objects
- Updates only specified properties
- Convenient for nested objects
// Direct partialstate.patch({ prop: newValue });
// Function form (async-safe)state.patch((current) => ({ prop: newValue }));Key Takeaways
Section titled “Key Takeaways”- Primitives: Use
.set()only - Objects: Both work, but
.patch()is more convenient - Async: Always use function form
(current) => ... - Deep merge: Use
.patch()for nested objects - Complete replacement: Use
.set()
Next Steps
Section titled “Next Steps”- State Keys and Singleton Pattern - Understand how state is shared
- Event Management - Learn about listening to state changes
- API Reference - Complete API documentation