Skip to content

Updating State

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 completely replaces the current state with a new value.

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 } }
}
}

Pass the new state value directly:

const counter = withLocalState(this, 'counter', 0);
// Direct value
counter.set(10);
console.log(counter.get()); // 10
counter.set(counter.get() + 1);
console.log(counter.get()); // 11

Pass a function that receives the current state and returns the new state:

const counter = withLocalState(this, 'counter', 0);
// Function form - receives current state
counter.set((currentState) => currentState + 1);
console.log(counter.get()); // 1
counter.set((currentState) => currentState * 2);
console.log(counter.get()); // 2

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();
}
}

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 properties
player.set({
...player.get(),
hp: 90,
});
console.log(player.get()); // { hp: 90, mp: 50, level: 1 }
// ✅ Or use function form
player.set((current) => ({
...current,
hp: 90,
}));

The .patch() method performs a deep merge, updating only the keys you specify while preserving everything else.

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 } }
}
}

.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 specify
player.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'] }
// }

Pass the partial object directly:

const player = withLocalState(this, 'player', {
hp: 100,
mp: 50,
level: 1,
});
// Direct partial update
player.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 }

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 state
player.patch((current) => ({
hp: current.hp - 10,
}));
console.log(player.get()); // { hp: 90, mp: 50, level: 1 }
// Calculate based on current state
player.patch((current) => ({
mp: current.mp + current.level * 5,
}));
console.log(player.get()); // { hp: 90, mp: 55, level: 1 }

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);
});
}
}
Feature.set().patch()
BehaviorReplaces entire stateDeep 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

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

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
}
}

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 }
}
}

.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
}
}

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,
}));
}
}

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
}));
});
}
}
// ✅ 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 });
// ✅ ALWAYS use function form in async operations
this.time.delayedCall(1000, () => {
score.set((current) => current + 10);
});
// ✅ ALWAYS use function form in event handlers
this.events.on('collect-coin', () => {
player.patch((current) => ({
gold: current.gold + 1,
}));
});
// ❌ AVOID direct access in async
this.time.delayedCall(1000, () => {
score.set(score.get() + 10); // Might be stale!
});

When working with objects, .patch() is more readable:

// ❌ Harder to read
player.set({
...player.get(),
hp: player.get().hp - damage,
mp: player.get().mp - manaCost,
});
// ✅ Clear and concise
player.patch((current) => ({
hp: current.hp - damage,
mp: current.mp - manaCost,
}));

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() properties
player.patch({ hp: 90 }); // OK
player.patch({ invalidProp: 10 }); // TypeScript error!
// ✅ TypeScript ensures .set() has all required properties
player.set({ hp: 90, mp: 50, level: 1 }); // OK
player.set({ hp: 90 }); // TypeScript error - missing properties!
const counter = withLocalState(this, 'counter', 0);
// ❌ Won't work - .patch() requires objects
// counter.patch(5); // Error!
// ✅ Use .set() instead
counter.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 });
// or
player.patch({ hp: 90 });

Mistake 3: Not Using Function Form in Async

Section titled “Mistake 3: Not Using Function Form in Async”
// ❌ Race condition risk
this.time.delayedCall(100, () => {
score.set(score.get() + 10);
});
this.time.delayedCall(100, () => {
score.set(score.get() + 20);
});
// Might lose one update!
// ✅ Safe with function form
this.time.delayedCall(100, () => {
score.set((current) => current + 10);
});
this.time.delayedCall(100, () => {
score.set((current) => current + 20);
});
// Both updates will apply correctly
  • Replaces entire state
  • Works with any type (primitives, objects, arrays)
  • Requires all properties for objects
  • Use when you need complete control
// Direct value
state.set(newValue);
// Function form (async-safe)
state.set((current) => newValue);
  • Performs deep merge
  • Only works with objects
  • Updates only specified properties
  • Convenient for nested objects
// Direct partial
state.patch({ prop: newValue });
// Function form (async-safe)
state.patch((current) => ({ prop: newValue }));
  1. Primitives: Use .set() only
  2. Objects: Both work, but .patch() is more convenient
  3. Async: Always use function form (current) => ...
  4. Deep merge: Use .patch() for nested objects
  5. Complete replacement: Use .set()