Skip to content

Reactivity and Event Handling

Phaser Hooks is not automatically reactive. When state changes, you need to manually update your scene elements (sprites, text, UI) by listening to change events. This gives you complete control over what happens when state updates.

Phaser Hooks (Manual Reactivity):

// You must manually update scene elements
const count = withLocalState(this, 'count', 0);
const text = this.add.text(100, 100, `Count: ${count.get()}`);
// Listen to changes and update manually
count.on('change', (newValue) => {
text.setText(`Count: ${newValue}`); // Manual update
});

Manual reactivity in game development offers:

  1. Performance Control - Update only what you need, when you need
  2. Fine-grained Updates - Choose exactly which elements respond to changes
  3. Batching - Group multiple updates together for efficiency
  4. Game Logic Control - React to state changes with custom game logic

The .on('change', callback) method subscribes to all state changes. Every time the state updates, your callback function is executed.

import { withLocalState } from 'phaser-hooks';
export class GameScene extends Phaser.Scene {
create() {
const score = withLocalState(this, 'score', 0);
// Listen to all changes
score.on('change', (currentState, oldState) => {
console.log('Score changed!');
console.log('Old:', oldState); // 0
console.log('New:', currentState); // 100
});
score.set(100); // Triggers the callback
score.set(200); // Triggers again
score.set(300); // Triggers again
}
}

The callback receives two parameters:

state.on('change', (currentState, oldState) => {
// currentState: The NEW state value after the update
// oldState: The PREVIOUS state value before the update
});

Example with object state:

const player = withLocalState(this, 'player', {
hp: 100,
level: 1,
});
player.on('change', (current, old) => {
console.log('HP changed from', old.hp, 'to', current.hp);
console.log('Level changed from', old.level, 'to', current.level);
});
player.patch({ hp: 90 });
// Logs: "HP changed from 100 to 90"
// Logs: "Level changed from 1 to 1"

You can also call .get() inside the callback to access the current state:

const player = withLocalState(this, 'player', { hp: 100, mp: 50 });
player.on('change', (current, old) => {
// Both work - they're equivalent
console.log('Current HP:', current.hp);
console.log('Current HP:', player.get().hp); // Same value
// Use .get() if you need the most up-to-date value
// (useful if callback logic is complex)
});
export class GameScene extends Phaser.Scene {
private healthBar: Phaser.GameObjects.Graphics;
private healthText: Phaser.GameObjects.Text;
create() {
const player = withLocalState(this, 'player', {
hp: 100,
maxHp: 100,
});
// Create visual elements
this.healthBar = this.add.graphics();
this.healthText = this.add.text(10, 10, '', { fontSize: '16px' });
// Listen to changes and update visuals
player.on('change', (current, old) => {
// Update health bar
this.healthBar.clear();
this.healthBar.fillStyle(0xff0000);
const width = (current.hp / current.maxHp) * 200;
this.healthBar.fillRect(10, 30, width, 20);
// Update text
this.healthText.setText(`HP: ${current.hp}/${current.maxHp}`);
// Play sound if HP decreased
if (current.hp < old.hp) {
this.sound.play('damage');
}
});
// Trigger initial render
player.patch({}); // or manually call the update logic once
// Simulate damage
this.time.addEvent({
delay: 1000,
callback: () => {
player.patch((c) => ({ hp: Math.max(0, c.hp - 10) }));
},
loop: true,
});
}
}

The .once('change', callback) method subscribes to only the first state change. After the first change, it automatically unsubscribes.

export class GameScene extends Phaser.Scene {
create() {
const player = withLocalState(this, 'player', { level: 1 });
// Only fires on the FIRST change
player.once('change', (current, old) => {
console.log('Level up!', current.level);
this.sound.play('levelup');
});
player.patch({ level: 2 }); // ✅ Fires callback
player.patch({ level: 3 }); // ❌ Does NOT fire
player.patch({ level: 4 }); // ❌ Does NOT fire
}
}

Use .once() for one-time events:

export class GameScene extends Phaser.Scene {
create() {
const tutorial = withLocalState(this, 'tutorial', {
completed: false,
});
// Show celebration only once
tutorial.once('change', (current) => {
if (current.completed) {
this.showCelebration();
this.unlockNextLevel();
}
});
// First time completing tutorial
tutorial.patch({ completed: true }); // ✅ Shows celebration
// Won't trigger again if tutorial is reset and completed again
tutorial.patch({ completed: false });
tutorial.patch({ completed: true }); // ❌ No celebration
}
showCelebration() {
console.log('🎉 Tutorial Complete!');
}
unlockNextLevel() {
console.log('Level 2 unlocked!');
}
}

Perfect for detecting the first time something happens:

const achievements = withLocalState(this, 'achievements', {
firstKill: false,
firstDeath: false,
firstLevelUp: false,
});
// Achievement unlocked on first kill
achievements.once('change', (current, old) => {
if (current.firstKill && !old.firstKill) {
this.unlockAchievement('First Blood');
}
});

Both .on() and .once() return an unsubscribe function. Calling this function removes the listener.

export class GameScene extends Phaser.Scene {
create() {
const score = withLocalState(this, 'score', 0);
// .on() returns an unsubscribe function
const unsubscribe = score.on('change', (current) => {
console.log('Score:', current);
});
score.set(100); // ✅ Fires callback
score.set(200); // ✅ Fires callback
// Unsubscribe
unsubscribe();
score.set(300); // ❌ Does NOT fire - listener was removed
}
}

Even though .once() auto-unsubscribes, you can manually cancel it before it fires:

export class GameScene extends Phaser.Scene {
create() {
const player = withLocalState(this, 'player', { level: 1 });
const unsubscribe = player.once('change', (current) => {
console.log('This might never run');
});
// Cancel before it fires
unsubscribe();
player.patch({ level: 2 }); // ❌ Does NOT fire - was cancelled
}
}

Essential pattern for scene lifecycle management:

export class GameScene extends Phaser.Scene {
private unsubscribeScore?: () => void;
private unsubscribePlayer?: () => void;
create() {
const score = withLocalState(this, 'score', 0);
const player = withLocalState(this, 'player', { hp: 100 });
// Store unsubscribe functions
this.unsubscribeScore = score.on('change', (current) => {
console.log('Score:', current);
});
this.unsubscribePlayer = player.on('change', (current) => {
console.log('HP:', current.hp);
});
}
shutdown() {
// Clean up when scene is destroyed
this.unsubscribeScore?.();
this.unsubscribePlayer?.();
}
}

Local state automatically cleans up when scenes are destroyed, but global state persists:

export class GameScene extends Phaser.Scene {
private unsubscribeSettings?: () => void;
create() {
// Global state persists across scenes
const settings = withGlobalState(this, 'settings', {
volume: 0.8,
});
// ⚠️ This listener will persist even after scene is destroyed!
this.unsubscribeSettings = settings.on('change', (current) => {
// This code will try to access scene elements that might not exist!
this.sound.setVolume(current.volume); // ERROR if scene is destroyed
});
}
shutdown() {
// ✅ CRITICAL: Unsubscribe from global state
this.unsubscribeSettings?.();
}
}

Manage multiple subscriptions with an array:

export class GameScene extends Phaser.Scene {
private unsubscribes: Array<() => void> = [];
create() {
const player = withLocalState(this, 'player', { hp: 100, mp: 50 });
const score = withLocalState(this, 'score', 0);
const enemies = withLocalState(this, 'enemies', []);
// Collect all unsubscribe functions
this.unsubscribes.push(
player.on('change', (c) => this.updatePlayerUI(c)),
score.on('change', (c) => this.updateScoreUI(c)),
enemies.on('change', (c) => this.updateEnemyCount(c))
);
}
shutdown() {
// Unsubscribe from all at once
this.unsubscribes.forEach(unsub => unsub());
this.unsubscribes = [];
}
updatePlayerUI(player: any) {
console.log('Player:', player);
}
updateScoreUI(score: number) {
console.log('Score:', score);
}
updateEnemyCount(enemies: any[]) {
console.log('Enemies:', enemies.length);
}
}

The .off(event, callback) method removes a specific listener. However, you cannot use anonymous functions with .off() - you must store the function reference.

export class GameScene extends Phaser.Scene {
private onScoreChange: (current: number, old: number) => void;
create() {
const score = withLocalState(this, 'score', 0);
// Store function reference
this.onScoreChange = (current, old) => {
console.log('Score changed from', old, 'to', current);
};
// Subscribe
score.on('change', this.onScoreChange);
score.set(100); // ✅ Fires
// Unsubscribe using .off()
score.off('change', this.onScoreChange);
score.set(200); // ❌ Does NOT fire
}
}
const score = withLocalState(this, 'score', 0);
// ❌ This WON'T work - different function instances
score.on('change', (current) => {
console.log('Score:', current);
});
score.off('change', (current) => {
console.log('Score:', current);
});
// Listener is NOT removed - different function instance!
// ✅ This WORKS - same function instance
const callback = (current: number) => {
console.log('Score:', current);
};
score.on('change', callback);
score.off('change', callback); // Listener removed

Prefer the unsubscribe function:

// ✅ Recommended - simpler and cleaner
const unsubscribe = score.on('change', (current) => {
console.log('Score:', current);
});
unsubscribe(); // Easy!
// ❌ More verbose - requires storing function reference
const callback = (current: number) => {
console.log('Score:', current);
};
score.on('change', callback);
score.off('change', callback); // Works, but more boilerplate

Use .off() when:

  • You need to add/remove the same listener multiple times
  • You’re managing listeners across multiple parts of your code
  • You have a named function that needs to be reused

The .clearListeners() method removes all listeners from a state at once.

export class GameScene extends Phaser.Scene {
create() {
const player = withLocalState(this, 'player', { hp: 100 });
// Add multiple listeners
player.on('change', (c) => console.log('Listener 1:', c.hp));
player.on('change', (c) => console.log('Listener 2:', c.hp));
player.on('change', (c) => console.log('Listener 3:', c.hp));
player.patch({ hp: 90 });
// Logs:
// "Listener 1: 90"
// "Listener 2: 90"
// "Listener 3: 90"
// Remove ALL listeners at once
player.clearListeners();
player.patch({ hp: 80 }); // ❌ Nothing logs - all removed
}
}

Perfect for cleaning up all listeners when a scene ends:

export class GameScene extends Phaser.Scene {
private playerState: HookState<PlayerData>;
private scoreState: HookState<number>;
private settingsState: HookState<Settings>;
create() {
this.playerState = withLocalState(this, 'player', { hp: 100 });
this.scoreState = withLocalState(this, 'score', 0);
this.settingsState = withGlobalState(this, 'settings', {
volume: 0.8,
});
// Add many listeners
this.playerState.on('change', (c) => this.updateHealthBar(c));
this.playerState.on('change', (c) => this.checkDeath(c));
this.scoreState.on('change', (c) => this.updateScoreText(c));
this.settingsState.on('change', (c) => this.applySettings(c));
}
shutdown() {
// Clean up all at once
this.playerState?.clearListeners();
this.scoreState?.clearListeners();
this.settingsState?.clearListeners();
}
updateHealthBar(player: PlayerData) { /* ... */ }
checkDeath(player: PlayerData) { /* ... */ }
updateScoreText(score: number) { /* ... */ }
applySettings(settings: Settings) { /* ... */ }
}

Critical for global state to prevent errors and memory leaks:

export class MenuScene extends Phaser.Scene {
create() {
const globalSettings = withGlobalState(this, 'settings', {
volume: 0.8,
language: 'en',
});
// Many components might add listeners
globalSettings.on('change', (c) => this.updateUI(c));
globalSettings.on('change', (c) => this.applyAudio(c));
globalSettings.on('change', (c) => this.saveToLocalStorage(c));
// When transitioning to another scene
this.events.once('shutdown', () => {
// Remove ALL listeners to prevent issues
globalSettings.clearListeners();
});
}
updateUI(settings: any) { /* ... */ }
applyAudio(settings: any) { /* ... */ }
saveToLocalStorage(settings: any) { /* ... */ }
}
export class GameScene extends Phaser.Scene {
private scoreText: Phaser.GameObjects.Text;
private unsubscribe?: () => void;
create() {
const score = withLocalState(this, 'score', 0);
// Create text
this.scoreText = this.add.text(10, 10, 'Score: 0', {
fontSize: '24px',
color: '#ffffff',
});
// Update text when score changes
this.unsubscribe = score.on('change', (current, old) => {
this.scoreText.setText(`Score: ${current}`);
// Animate if score increased
if (current > old) {
this.tweens.add({
targets: this.scoreText,
scale: 1.2,
duration: 100,
yoyo: true,
});
}
});
// Increment score over time
this.time.addEvent({
delay: 1000,
callback: () => score.set((c) => c + 10),
loop: true,
});
}
shutdown() {
this.unsubscribe?.();
}
}

Example 2: Player Health with Visual Feedback

Section titled “Example 2: Player Health with Visual Feedback”
interface Player {
hp: number;
maxHp: number;
isDead: boolean;
}
export class GameScene extends Phaser.Scene {
private healthBar: Phaser.GameObjects.Graphics;
private playerSprite: Phaser.GameObjects.Sprite;
private unsubscribe?: () => void;
create() {
const player = withLocalState(this, 'player', {
hp: 100,
maxHp: 100,
isDead: false,
});
this.playerSprite = this.add.sprite(400, 300, 'player');
this.healthBar = this.add.graphics();
// React to health changes
this.unsubscribe = player.on('change', (current, old) => {
// Update health bar
this.updateHealthBar(current.hp, current.maxHp);
// Flash red when damaged
if (current.hp < old.hp) {
this.cameras.main.flash(200, 255, 0, 0, false);
this.playerSprite.setTint(0xff0000);
this.time.delayedCall(200, () => {
this.playerSprite.clearTint();
});
}
// Handle death
if (current.isDead && !old.isDead) {
this.handleDeath();
}
});
// Trigger initial render
player.patch({});
}
updateHealthBar(hp: number, maxHp: number) {
this.healthBar.clear();
// Background
this.healthBar.fillStyle(0x000000);
this.healthBar.fillRect(10, 10, 204, 24);
// Health
const width = (hp / maxHp) * 200;
const color = hp > 50 ? 0x00ff00 : hp > 25 ? 0xffff00 : 0xff0000;
this.healthBar.fillStyle(color);
this.healthBar.fillRect(12, 12, width, 20);
}
handleDeath() {
this.playerSprite.setAlpha(0.5);
this.add.text(400, 200, 'GAME OVER', {
fontSize: '64px',
color: '#ff0000',
}).setOrigin(0.5);
}
shutdown() {
this.unsubscribe?.();
}
}
export class GameScene extends Phaser.Scene {
private unsubscribes: Array<() => void> = [];
create() {
const player = withLocalState(this, 'player', { level: 1, exp: 0 });
const enemies = withLocalState(this, 'enemies', [] as Enemy[]);
const gameState = withLocalState(this, 'game', { paused: false });
// Sync player level with UI
this.unsubscribes.push(
player.on('change', (current) => {
this.updatePlayerUI(current);
})
);
// Spawn enemies when level increases
this.unsubscribes.push(
player.on('change', (current, old) => {
if (current.level > old.level) {
this.spawnEnemiesForLevel(current.level);
}
})
);
// Update enemy counter
this.unsubscribes.push(
enemies.on('change', (current) => {
this.updateEnemyCounter(current.length);
})
);
// Handle pause state
this.unsubscribes.push(
gameState.on('change', (current) => {
if (current.paused) {
this.scene.pause();
} else {
this.scene.resume();
}
})
);
}
shutdown() {
this.unsubscribes.forEach(unsub => unsub());
this.unsubscribes = [];
}
updatePlayerUI(player: any) {
console.log('Player UI updated:', player);
}
spawnEnemiesForLevel(level: number) {
console.log('Spawning enemies for level:', level);
}
updateEnemyCounter(count: number) {
console.log('Enemy count:', count);
}
}
interface Enemy {
id: string;
hp: number;
}
interface Settings {
volume: number;
musicEnabled: boolean;
language: string;
}
export class BaseScene extends Phaser.Scene {
protected unsubscribes: Array<() => void> = [];
create() {
const settings = withGlobalState(this, 'settings', {
volume: 0.8,
musicEnabled: true,
language: 'en',
});
// Apply settings in every scene
this.unsubscribes.push(
settings.on('change', (current) => {
this.sound.setVolume(current.volume);
if (current.musicEnabled) {
this.sound.resumeAll();
} else {
this.sound.pauseAll();
}
})
);
// Apply settings immediately
settings.patch({});
}
shutdown() {
// CRITICAL: Clean up global state listeners
this.unsubscribes.forEach(unsub => unsub());
this.unsubscribes = [];
}
}
// Inherit from BaseScene
export class MenuScene extends BaseScene {
create() {
super.create(); // Sets up settings listener
// Menu-specific logic
}
}
export class GameScene extends BaseScene {
create() {
super.create(); // Sets up settings listener
// Game-specific logic
}
}
// ✅ ALWAYS clean up global state listeners
export class GameScene extends Phaser.Scene {
private unsubscribe?: () => void;
create() {
const settings = withGlobalState(this, 'settings', { volume: 0.8 });
this.unsubscribe = settings.on('change', (c) => {
this.applySettings(c);
});
}
shutdown() {
this.unsubscribe?.(); // CRITICAL
}
applySettings(settings: any) { /* ... */ }
}
// ✅ Manage multiple subscriptions cleanly
export class GameScene extends Phaser.Scene {
private unsubscribes: Array<() => void> = [];
create() {
this.unsubscribes.push(
state1.on('change', () => { /* ... */ }),
state2.on('change', () => { /* ... */ }),
state3.on('change', () => { /* ... */ })
);
}
shutdown() {
this.unsubscribes.forEach(unsub => unsub());
this.unsubscribes = [];
}
}

3. Prefer Unsubscribe Function over .off()

Section titled “3. Prefer Unsubscribe Function over .off()”
// ✅ Simpler - use unsubscribe function
const unsubscribe = state.on('change', (c) => {
console.log(c);
});
unsubscribe();
// ❌ More complex - requires storing reference
const callback = (c: any) => console.log(c);
state.on('change', callback);
state.off('change', callback);
// ✅ Perfect for one-time events
player.once('change', (current) => {
if (current.level === 10) {
this.unlockAchievement('Reached Level 10');
}
});
// ❌ Requires manual unsubscribe
const unsub = player.on('change', (current) => {
if (current.level === 10) {
this.unlockAchievement('Reached Level 10');
unsub(); // Have to remember to unsubscribe
}
});

State listeners only fire on changes, not on creation:

const player = withLocalState(this, 'player', { hp: 100 });
const text = this.add.text(10, 10, '');
player.on('change', (current) => {
text.setText(`HP: ${current.hp}`);
});
// ✅ Trigger initial render
player.patch({}); // Empty patch triggers listener
// or
text.setText(`HP: ${player.get().hp}`); // Manual initial set

Avoid unnecessary updates by comparing values:

player.on('change', (current, old) => {
// Only update if HP actually changed
if (current.hp !== old.hp) {
this.updateHealthBar(current.hp);
}
// Only show level up animation if level increased
if (current.level > old.level) {
this.showLevelUpAnimation();
}
});

Mistake 1: Not Unsubscribing from Global State

Section titled “Mistake 1: Not Unsubscribing from Global State”
// ❌ Memory leak and potential errors
export class GameScene extends Phaser.Scene {
create() {
const settings = withGlobalState(this, 'settings', { volume: 0.8 });
settings.on('change', (current) => {
this.sound.setVolume(current.volume); // ERROR when scene is destroyed!
});
// No unsubscribe!
}
}
// ✅ Always unsubscribe
export class GameScene extends Phaser.Scene {
private unsubscribe?: () => void;
create() {
const settings = withGlobalState(this, 'settings', { volume: 0.8 });
this.unsubscribe = settings.on('change', (current) => {
this.sound.setVolume(current.volume);
});
}
shutdown() {
this.unsubscribe?.();
}
}

Mistake 2: Using Anonymous Functions with .off()

Section titled “Mistake 2: Using Anonymous Functions with .off()”
const score = withLocalState(this, 'score', 0);
// ❌ Won't work - different function instance
score.on('change', (c) => console.log(c));
score.off('change', (c) => console.log(c)); // Listener NOT removed
// ✅ Store function reference
const callback = (c: number) => console.log(c);
score.on('change', callback);
score.off('change', callback); // Works

Mistake 3: Forgetting to Trigger Initial Render

Section titled “Mistake 3: Forgetting to Trigger Initial Render”
const score = withLocalState(this, 'score', 100);
const text = this.add.text(10, 10, '');
// ❌ Text starts empty!
score.on('change', (current) => {
text.setText(`Score: ${current}`);
});
// Text won't show 100 until first change
// ✅ Trigger initial render
score.patch({}); // or manually set once
text.setText(`Score: ${score.get()}`);

Mistake 4: Not Cleaning Up .once() When Needed

Section titled “Mistake 4: Not Cleaning Up .once() When Needed”
// ❌ Scene might change before .once() fires
player.once('change', (current) => {
this.scene.start('GameOver'); // Might error if scene already changed
});
// ✅ Store and clean up if scene changes first
const unsub = player.once('change', (current) => {
this.scene.start('GameOver');
});
this.events.once('shutdown', () => {
unsub(); // Cancel if scene ends first
});
MethodDescriptionAuto-unsubscribeReturns
.on('change', callback)Listen to all changes❌ NoUnsubscribe function
.once('change', callback)Listen to first change only✅ Yes (after first)Unsubscribe function
.off('change', callback)Remove specific listenerN/Avoid
.clearListeners()Remove all listenersN/Avoid
(currentState: T, oldState: T) => void
  • currentState: New state after update
  • oldState: Previous state before update
  1. Manual Reactivity - You must manually update scene elements in listeners
  2. Always Unsubscribe - Critical for global state to prevent errors and leaks
  3. Use Unsubscribe Function - Simpler than .off()
  4. Use .once() for One-Time Events - Automatic cleanup
  5. Store Subscriptions - Use arrays or properties to manage multiple listeners
  6. Clean Up in shutdown() - Essential for proper scene lifecycle
  7. Trigger Initial Render - Listeners don’t fire on state creation