Skip to main content

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​

ObservableServerClientWebSync Direction
serverOwned✅ Read/Write🔒 Read-only🔒 Read-onlyServer → Client/Web
clientOwned🔒 Read-only✅ Read/Write🔒 Read-onlyClient → Server/Web
webOwned✅ Can update✅ Can update✅ Read/WriteClient/Server → Web
shared✅ Read/Write✅ Read/Write🔒 Read-onlyBidirectional
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​

TypeOwnerSync DirectionUse Case
serverOnlyServerNoneServer-internal state
clientOnlyClientNoneClient-internal state
serverOwnedServerServer → ClientsAuthoritative server state
clientOwnedClientClient → ServerClient-authoritative state
webOwnedWebClient/Server → WebUI state (client/server can update)
sharedBothBidirectionalCollaborative state

When to Use Each Type​

TypeUse When
serverOwnedServer is authoritative (player stats, game state, economy)
clientOwnedClient is authoritative (input state, local preferences)
webOwnedUI state that client/server needs to update
serverOnlyServer-internal state that doesn't need sync
clientOnlyClient-internal state that doesn't need sync
sharedCollaborative 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​

OptionBehaviorUse Case
perPlayer: trueEach player has their own valuePlayer stats, inventory
broadcast: trueAll players receive same valueGame state, time
NeitherManual subscription requiredSelective 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​

FunctionReturnsDescription
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​

MethodDescription
.valueGet 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​

  1. Use webOwned for UI state - Primary way to manage HUD/page state
  2. Use serverOwned for authoritative data - Player stats, game state
  3. Use perPlayer for player-specific state - Automatic per-player routing
  4. Prefer serverOwned - Server authority prevents cheating
  5. Use broadcast sparingly - Only for truly global state
  6. Clean up subscriptions - Use DisposableStore for cleanup
  7. Consider RPC alternatives - Not everything needs to be reactive
  8. Use debouncing - For high-frequency updates like position
  9. Custom equality for objects - Prevent unnecessary updates

Decision Guide​