Observables
The observable system provides reactive state management with automatic synchronization across client, server, and web boundaries.
Architecture Overview
The observable system synchronizes state between server, client, and web using FiveM's emitNet/onNet events and NUI callbacks.
Ownership Model
| Observable | Server | Client | Web | Sync Direction |
|---|---|---|---|---|
serverOwned | ✅ Read/Write | 🔒 Read-only | 🔒 Read-only | Server → Client/Web |
clientOwned | 🔒 Read-only | ✅ Read/Write | 🔒 Read-only | Client → Server/Web |
webOwned | ✅ Can update | ✅ Can update | ✅ Read/Write | Client/Server → Web |
shared | ✅ Read/Write | ✅ Read/Write | 🔒 Read-only | Bidirectional |
serverOnly | ✅ Read/Write | — | — | None |
clientOnly | — | ✅ Read/Write | — | None |
Sync Patterns
Server-Owned (Server is authoritative):
Client-Owned (Client is authoritative):
Web-Owned (Web is source of truth, others can update):
Observable Types
| Type | Owner | Sync Direction | Use Case |
|---|---|---|---|
serverOnly | Server | None | Server-internal state |
clientOnly | Client | None | Client-internal state |
serverOwned | Server | Server → Clients | Authoritative server state |
clientOwned | Client | Client → Server | Client-authoritative state |
webOwned | Web | Client/Server → Web | UI state (client/server can update) |
shared | Both | Bidirectional | Collaborative state |
When to Use Each Type
| Type | Use When |
|---|---|
serverOwned | Server is authoritative (player stats, game state, economy) |
clientOwned | Client is authoritative (input state, local preferences) |
webOwned | UI state that client/server needs to update |
serverOnly | Server-internal state that doesn't need sync |
clientOnly | Client-internal state that doesn't need sync |
shared | Collaborative state where both can write |
webOwned Observables
webOwned is the primary way to manage UI state. The web runtime is the source of truth, but client and server can update it:
import { webOwned } from "@core/observable";
// In a service
export class NeedsService {
public hudState = webOwned<HudState>({
id: "needs:hud",
initialValue: { hunger: 100, thirst: 100, health: 100, armor: 0 },
});
@ClientOnly
initClientSide(): void {
// Client can update the web observable
setInterval(() => {
const health = GetEntityHealth(PlayerPedId());
this.hudState.set({
...this.hudState.value,
health,
});
}, 1000);
}
@ServerOnly
updatePlayerNeeds(playerId: number, hunger: number, thirst: number): void {
// Server can also update the web observable
this.hudState.set({
...this.hudState.value,
hunger,
thirst,
});
}
}Using webOwned in UI Components
// src/modules/needs/huds/status/index.tsx
"use web";
import React from "react";
import { useService, useObservable } from "@core/react";
import { NeedsService } from "@modules/core/needs/needs.service";
export default function StatusPanel() {
const needsService = useService(NeedsService);
const state = useObservable(needsService.hudState);
return (
<div className="status-panel">
<div>Hunger: {state.hunger}%</div>
<div>Thirst: {state.thirst}%</div>
<div>Health: {state.health}%</div>
<div>Armor: {state.armor}%</div>
</div>
);
}Server-Owned Observables
The server has read/write access, clients and web have read-only:
import { serverOwned } from "@core/observable";
// Global state - broadcast to all players
const gameState = serverOwned({
id: "game:state",
initialValue: { phase: "lobby", timer: 0 },
broadcast: true,
});
// Per-player state - each player gets their own instance
const playerNeeds = serverOwned({
id: "player:needs",
initialValue: { hunger: 100, thirst: 100 },
perPlayer: true,
});Server-Side Usage
// Update state (server only)
gameState.set({ phase: "playing", timer: 300 });
// Read state
const current = gameState.value;
// Subscribe to changes
gameState.subscribe((value) => {
console.log("Game state changed:", value);
});
// Per-player: update specific player
playerNeeds.set({ hunger: 80, thirst: 90 }, playerId);Client-Side Usage
// Read state (client)
const state = gameState.value;
// Subscribe to updates from server
gameState.subscribe((value) => {
updateUI(value);
});
// ❌ Cannot set on client - produces no-op
// gameState.set({ phase: "ended" });Client-Owned Observables
The client has read/write access, server has read-only:
import { clientOwned } from "@core/observable";
const playerInput = clientOwned({
id: "player:input",
initialValue: { x: 0, y: 0 },
});Shared Observables
Both client and server can read/write with conflict resolution:
import { shared } from "@core/observable";
const position = shared({
id: "player:position",
initialValue: { x: 0, y: 0, z: 0 },
// Built-in strategies
conflictResolution: "last-write-wins",
// conflictResolution: "server-wins",
// conflictResolution: "client-wins",
// Or custom resolver
conflictResolution: (serverVal, clientVal, serverTime, clientTime) => {
return clientTime > serverTime ? clientVal : serverVal;
},
});Player Subscriptions
By default, players must be manually subscribed to synchronized observables:
import { IS_SERVER } from "@core/runtime";
// Manual subscription
const inventory = serverOwned({
id: "player:inventory",
initialValue: { items: [] },
});
// Subscribe a specific player (server-side)
if (IS_SERVER) {
inventory.subscribePlayer(playerId);
}
// Or use broadcast: true for auto-subscription
const globalState = serverOwned({
id: "game:global",
initialValue: { online: 0 },
broadcast: true, // All players receive updates
});Per-Player vs Global Observables
| Option | Behavior | Use Case |
|---|---|---|
perPlayer: true | Each player has their own value | Player stats, inventory |
broadcast: true | All players receive same value | Game state, time |
| Neither | Manual subscription required | Selective sync |
Lazy Registration
Observables use lazy registration - handlers are only registered when first accessed:
export class NeedsService {
// Safe to declare as field - registration deferred
private needs = serverOwned<NeedsState>({
id: "needs:player",
initialValue: { hunger: 100, thirst: 100 },
perPlayer: true,
});
@ClientOnly
initClientSide(): void {
// First access triggers registration
this.needs.subscribe((value) => {
console.log("Needs updated:", value);
});
}
}Computed Observables
Derive values from other observables:
import { serverOwned, computed } from "@core/observable";
const health = serverOwned({ id: "player:health", initialValue: 100 });
const maxHealth = serverOwned({ id: "player:maxHealth", initialValue: 100 });
// Automatically updates when dependencies change
const healthPercent = computed("player:healthPercent", () => (health.value / maxHealth.value) * 100, [
health,
maxHealth,
]);Piping Observables
Connect observables with transformations:
const health = serverOwned({ id: "player:health", initialValue: 100 });
const healthDisplay = serverOwned({ id: "player:healthDisplay", initialValue: "100%" });
// Pipe health to display with transformation
healthDisplay.pipe(health, (h) => `${h}%`);Complete Example
// src/modules/needs/needs.service.ts
import { webOwned, serverOwned } from "@core/observable";
interface NeedsState {
hunger: number;
thirst: number;
health: number;
armor: number;
}
export class NeedsService {
// Web-owned for UI updates
public hudState = webOwned<NeedsState>({
id: "needs:hud",
initialValue: { hunger: 100, thirst: 100, health: 100, armor: 0 },
});
// Server-owned for authoritative player state
private playerNeeds = serverOwned<{ hunger: number; thirst: number }>({
id: "needs:player",
initialValue: { hunger: 100, thirst: 100 },
perPlayer: true,
});
private decayTimer: ReturnType<typeof setInterval> | null = null;
private hudTimer: ReturnType<typeof setInterval> | null = null;
@ServerOnly
initServerSide(): void {
// Decay needs every minute
this.decayTimer = setInterval(() => {
for (const [playerId] of this.playerNeeds.entries()) {
const current = this.playerNeeds.get(playerId);
if (current) {
this.playerNeeds.set(
{
hunger: Math.max(0, current.hunger - 1),
thirst: Math.max(0, current.thirst - 2),
},
playerId,
);
}
}
}, 60000);
}
@ClientOnly
initClientSide(): void {
// Update HUD with game state every second
this.hudTimer = setInterval(() => {
const ped = PlayerPedId();
this.hudState.set({
hunger: this.playerNeeds.value?.hunger ?? 100,
thirst: this.playerNeeds.value?.thirst ?? 100,
health: GetEntityHealth(ped),
armor: GetPedArmour(ped),
});
}, 1000);
}
@ServerOnly
cleanupServerSide(): void {
if (this.decayTimer) {
clearInterval(this.decayTimer);
this.decayTimer = null;
}
}
@ClientOnly
cleanupClientSide(): void {
if (this.hudTimer) {
clearInterval(this.hudTimer);
this.hudTimer = null;
}
}
}API Reference
Factory Functions
| Function | Returns | Description |
|---|---|---|
serverOnly<T>(options) | WritableObservable<T> | Server-only, no sync |
clientOnly<T>(options) | WritableObservable<T> | Client-only, no sync |
serverOwned<T>(options) | ServerOwnedObservable<T> | Server R/W, client/web RO |
clientOwned<T>(options) | ClientOwnedObservable<T> | Client R/W, server/web RO |
webOwned<T>(options) | WebOwnedObservable<T> | Web R/W, client/server can update |
shared<T>(options) | SharedObservable<T> | Both R/W with conflict resolution |
computed<T>(id, fn, sources) | ReadonlyObservable<T> | Derived from other observables |
Observable Options
interface ObservableOptions<T> {
id: string; // Required: unique identifier
initialValue: T; // Required: starting value
broadcast?: boolean; // Auto-subscribe all players (default: false)
perPlayer?: boolean; // Per-player instances (default: false)
equals?: (a: T, b: T) => boolean; // Custom equality check
syncDebounceMs?: number; // Debounce sync updates (default: 0)
noResponse?: boolean; // Fire-and-forget sync (default: true)
}Observable Methods
| Method | Description |
|---|---|
.value | Get current value |
.set(value, playerId?) | Set new value (notifies if changed) |
.update(fn) | Update using function: (current) => newValue |
.subscribe(callback) | Subscribe to changes, returns Subscription |
.pipe(source, transform?) | Subscribe to another observable |
.subscribePlayer(id) | Subscribe a player (server-owned) |
.unsubscribePlayer(id) | Unsubscribe a player |
.get(playerId) | Get a specific player's value (per-player) |
.entries() | Iterate all player entries (per-player) |
.dispose() | Clean up resources |
Best Practices
- Use webOwned for UI state - Primary way to manage HUD/page state
- Use serverOwned for authoritative data - Player stats, game state
- Use perPlayer for player-specific state - Automatic per-player routing
- Prefer serverOwned - Server authority prevents cheating
- Use broadcast sparingly - Only for truly global state
- Clean up subscriptions - Use
DisposableStorefor cleanup - Consider RPC alternatives - Not everything needs to be reactive
- Use debouncing - For high-frequency updates like position
- Custom equality for objects - Prevent unnecessary updates