With Computed State
The withComputedState hook creates derived state that automatically recomputes when its source state changes. Think of it like Vue’s computed properties or React’s useMemo - perfect for calculations, transformations, and derived values.
How It Works
Section titled “How It Works”withComputedState takes a source state and a selector function, and creates a new state that:
- ✅ Auto-updates - Recalculates whenever the source state changes
- ✅ Read-only - The computed state itself cannot be directly modified
- ✅ Reactive - Triggers its own
'change'listeners when the value changes - ✅ Type-safe - Full TypeScript inference from source to result
const player = withLocalState(this, 'player', { hp: 100, maxHp: 100 });
// Create computed stateconst healthPercent = withComputedState( this, 'healthPercent', player, (p) => Math.round((p.hp / p.maxHp) * 100));
console.log(healthPercent.get()); // 100
// When source changes, computed updates automaticallyplayer.patch({ hp: 50 });console.log(healthPercent.get()); // 50Creating Custom Hooks with Computed State
Section titled “Creating Custom Hooks with Computed State”Combine computed state with custom hooks for powerful derived values:
import { type HookState, withLocalState, withComputedState } from 'phaser-hooks';
type Player = { hp: number; maxHp: number; mp: number; maxMp: number; level: number; exp: number; expToNextLevel: number;};
type PlayerState = HookState<Player> & { healthPercent: HookState<number>; manaPercent: HookState<number>; levelProgress: HookState<number>; isLowHealth: HookState<boolean>; isDead: HookState<boolean>; takeDamage: (amount: number) => void; heal: (amount: number) => void;};
export function withPlayer(scene: Phaser.Scene): PlayerState { const state = withLocalState<Player>(scene, 'player', { hp: 100, maxHp: 100, mp: 50, maxMp: 50, level: 1, exp: 0, expToNextLevel: 100, });
// Computed values const healthPercent = withComputedState( scene, 'player:healthPercent', state, (p) => Math.round((p.hp / p.maxHp) * 100) );
const manaPercent = withComputedState( scene, 'player:manaPercent', state, (p) => Math.round((p.mp / p.maxMp) * 100) );
const levelProgress = withComputedState( scene, 'player:levelProgress', state, (p) => Math.round((p.exp / p.expToNextLevel) * 100) );
const isLowHealth = withComputedState( scene, 'player:isLowHealth', state, (p) => p.hp < p.maxHp * 0.25 );
const isDead = withComputedState( scene, 'player:isDead', state, (p) => p.hp <= 0 );
return { ...state, healthPercent, manaPercent, levelProgress, isLowHealth, isDead,
takeDamage: (amount: number) => { const current = state.get(); state.patch({ hp: Math.max(0, current.hp - amount) }); },
heal: (amount: number) => { const current = state.get(); state.patch({ hp: Math.min(current.maxHp, current.hp + amount) }); }, };}const { withLocalState, withComputedState } = require('phaser-hooks');
function withPlayer(scene) { const state = withLocalState(scene, 'player', { hp: 100, maxHp: 100, mp: 50, maxMp: 50, level: 1, exp: 0, expToNextLevel: 100, });
// Computed values const healthPercent = withComputedState( scene, 'player:healthPercent', state, (p) => Math.round((p.hp / p.maxHp) * 100) );
const manaPercent = withComputedState( scene, 'player:manaPercent', state, (p) => Math.round((p.mp / p.maxMp) * 100) );
const levelProgress = withComputedState( scene, 'player:levelProgress', state, (p) => Math.round((p.exp / p.expToNextLevel) * 100) );
const isLowHealth = withComputedState( scene, 'player:isLowHealth', state, (p) => p.hp < p.maxHp * 0.25 );
const isDead = withComputedState( scene, 'player:isDead', state, (p) => p.hp <= 0 );
return { ...state, healthPercent, manaPercent, levelProgress, isLowHealth, isDead,
takeDamage: (amount) => { const current = state.get(); state.patch({ hp: Math.max(0, current.hp - amount) }); },
heal: (amount) => { const current = state.get(); state.patch({ hp: Math.min(current.maxHp, current.hp + amount) }); }, };}
module.exports = { withPlayer };Usage Example
Section titled “Usage Example”Use computed values to drive UI and game logic:
import { withPlayer } from './hooks/withPlayer';
class GameScene extends Phaser.Scene { create() { const player = withPlayer(this);
// Create UI elements const healthBar = this.add.rectangle(10, 10, 200, 20, 0xff0000); const warningText = this.add.text(10, 40, '', { color: '#ff0000' });
// Update health bar when percentage changes player.healthPercent.on('change', (percent) => { healthBar.width = (200 * percent) / 100; });
// Show warning when low health player.isLowHealth.on('change', (isLow) => { warningText.setText(isLow ? 'LOW HEALTH!' : ''); });
// Game over when dead player.isDead.on('change', (dead) => { if (dead) { this.scene.start('GameOverScene'); } });
// Simulate taking damage this.input.keyboard?.on('keydown-SPACE', () => { player.takeDamage(25); }); }}
// HealthBar componentclass HealthBar extends Phaser.GameObjects.Container { private bar: Phaser.GameObjects.Rectangle; private text: Phaser.GameObjects.Text;
constructor(scene: Phaser.Scene) { super(scene, 0, 0);
const player = withPlayer(scene);
// Create visuals this.bar = scene.add.rectangle(0, 0, 200, 20, 0xff0000); this.text = scene.add.text(0, 25, '', { color: '#ffffff' });
this.add([this.bar, this.text]);
// Update on changes player.healthPercent.on('change', (percent) => { this.bar.width = (200 * percent) / 100; this.text.setText(`${player.get().hp} / ${player.get().maxHp}`); });
// Initial update const percent = player.healthPercent.get(); this.bar.width = (200 * percent) / 100; this.text.setText(`${player.get().hp} / ${player.get().maxHp}`); }}const { withPlayer } = require('./hooks/withPlayer');
class GameScene extends Phaser.Scene { create() { const player = withPlayer(this);
const healthBar = this.add.rectangle(10, 10, 200, 20, 0xff0000); const warningText = this.add.text(10, 40, '', { color: '#ff0000' });
player.healthPercent.on('change', (percent) => { healthBar.width = (200 * percent) / 100; });
player.isLowHealth.on('change', (isLow) => { warningText.setText(isLow ? 'LOW HEALTH!' : ''); });
player.isDead.on('change', (dead) => { if (dead) { this.scene.start('GameOverScene'); } });
this.input.keyboard.on('keydown-SPACE', () => { player.takeDamage(25); }); }}Common Use Cases
Section titled “Common Use Cases”Percentages and Progress Bars
Section titled “Percentages and Progress Bars”const player = withLocalState(this, 'player', { hp: 100, maxHp: 100 });
const healthPercent = withComputedState( this, 'healthPercent', player, (p) => (p.hp / p.maxHp) * 100);
healthBar.on('update', () => { healthBar.setScale(healthPercent.get() / 100, 1);});Boolean Flags
Section titled “Boolean Flags”const isAlive = withComputedState( this, 'isAlive', player, (p) => p.hp > 0);
const canLevelUp = withComputedState( this, 'canLevelUp', player, (p) => p.exp >= p.expToNextLevel);
canLevelUp.on('change', (can) => { if (can) { this.showLevelUpButton(); }});Derived Stats
Section titled “Derived Stats”const totalAttack = withComputedState( this, 'totalAttack', player, (p) => p.baseAttack + p.weaponAttack + p.buffAttack);
const critChance = withComputedState( this, 'critChance', player, (p) => Math.min(100, p.luck * 0.5 + p.critBonus));Formatted Strings
Section titled “Formatted Strings”const playerNameLevel = withComputedState( this, 'playerNameLevel', player, (p) => `${p.name} (Lv. ${p.level})`);
playerNameLevel.on('change', (text) => { nameText.setText(text);});Conditional Logic
Section titled “Conditional Logic”const combatStatus = withComputedState( this, 'combatStatus', player, (p) => { if (p.hp === 0) return 'dead'; if (p.hp < p.maxHp * 0.25) return 'critical'; if (p.hp < p.maxHp * 0.5) return 'wounded'; return 'healthy'; });
combatStatus.on('change', (status) => { this.updatePlayerColor(status);});Key Features
Section titled “Key Features”| Feature | Description |
|---|---|
| Auto-updates | Recomputes when source state changes |
| Reactive | Emits 'change' events when value changes |
| Read-only | Cannot directly modify computed state (modify source instead) |
| Type-safe | TypeScript infers result type from selector |
| Efficient | Only recalculates when source actually changes |
Behavior Notes
Section titled “Behavior Notes”Read-Only
Section titled “Read-Only”Computed state is read-only. To change it, modify the source state:
const healthPercent = withComputedState(this, 'hp%', player, p => p.hp / p.maxHp * 100);
healthPercent.set(50); // ❌ Don't do this! (will set the computed value directly)
player.patch({ hp: 50 }); // ✅ Correct - modify source, computed updates automaticallyChange Detection
Section titled “Change Detection”The computed state only emits 'change' when the result value changes, not when the source changes:
const isLowHealth = withComputedState( this, 'isLowHealth', player, (p) => p.hp < 25);
isLowHealth.on('change', () => console.log('Low health status changed!'));
player.patch({ hp: 30 }); // No log (result is still false)player.patch({ hp: 20 }); // Logs! (result changed from false to true)player.patch({ hp: 10 }); // No log (result is still true)Multiple Sources
Section titled “Multiple Sources”For deriving from multiple states, create an intermediate combined state:
const player = withLocalState(this, 'player', { level: 5 });const equipment = withLocalState(this, 'equipment', { weaponDamage: 10 });
// Combine sources firstconst combatStats = withComputedState( this, 'combatStats', player, (p) => ({ playerLevel: p.level, equipment: equipment.get() }));
// Then compute final valueconst totalDamage = withComputedState( this, 'totalDamage', combatStats, (stats) => stats.playerLevel * 2 + stats.equipment.weaponDamage);When to Use
Section titled “When to Use”✅ Use withComputedState for:
- Calculations and percentages
- Boolean flags derived from state
- Formatted strings for display
- Conditional logic based on multiple properties
- Performance-sensitive derived values (computed once, used many times)
❌ Don’t use withComputedState for:
- Simple property access (just use
.get()) - Values that change independently of state
- Heavy computations (consider memoization patterns instead)
function withComputedState<T, U>( scene: Phaser.Scene, key: string, sourceState: HookState<T>, selector: (sourceValue: T) => U): HookState<U>Parameters
Section titled “Parameters”scene- The Phaser scene instancekey- Unique identifier for the computed statesourceState- The source hook state to derive fromselector- Function that computes the derived value from source
Returns
Section titled “Returns”A read-only HookState<U> that automatically updates when source changes.
See Also
Section titled “See Also”- withLocalState - For source state
- Creating Custom Hooks - Combining computed with custom hooks
- Event Handling - Reacting to computed changes
Next Steps
Section titled “Next Steps”- Learn about withUndoableState for time-travel
- Explore real-world examples using computed state
- See performance tips for computed values