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