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/WriteNone
clientOnly✅ Read/WriteNone

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