Skip to main content

Client/UI Communication

True Life uses webOwned observables and @Web decorators for communication between the FiveM client and the React UI. This replaces the traditional sendHudEvent/useNuiEvent pattern with a more unified approach.

Primary Method: webOwned Observables

The recommended way to communicate between client and UI is through webOwned observables:

// src/modules/needs/needs.service.ts
import { webOwned } from "@core/observable";
 
interface HudState {
	hunger: number;
	thirst: number;
	health: number;
	armor: number;
}
 
export class NeedsService {
	public hudState = webOwned<HudState>({
		id: "needs:hud",
		initialValue: { hunger: 100, thirst: 100, health: 100, armor: 0 },
	});
 
	@ClientOnly
	initClientSide(): void {
		// Update the UI from client
		setInterval(() => {
			const ped = PlayerPedId();
			this.hudState.set({
				hunger: this.getHunger(),
				thirst: this.getThirst(),
				health: GetEntityHealth(ped),
				armor: GetPedArmour(ped),
			});
		}, 1000);
	}
}

The UI automatically reacts to changes:

// 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="rounded-lg bg-black/80 p-4">
			<div>Hunger: {state.hunger}%</div>
			<div>Thirst: {state.thirst}%</div>
			<div>Health: {state.health}%</div>
			<div>Armor: {state.armor}%</div>
		</div>
	);
}

Alternative: @Web Decorator

For more complex scenarios, use the @Web decorator. The @Web decorator can be called from both client and server:

// In service
export class MyService {
	public complexState = webOwned<ComplexState>({
		id: "my:complex",
		initialValue: {
			/* ... */
		},
	});
 
	@Web
	async updateComplexState(ctx: EventContext<[update: Partial<ComplexState>]>): Promise<void> {
		const [update] = ctx.args;
		// Complex logic before updating
		const validated = this.validateUpdate(update);
		this.complexState.set({
			...this.complexState.value,
			...validated,
		});
	}
}

Calling from Client

// Called from client - source can be 0
await myService.updateComplexState({
	source: 0,
	args: [{ field: "new value" }],
});

Calling from Server

When calling @Web methods from the server, the call is routed through the target player's client to their web UI:

// Called from server - source must be the target player ID
await myService.updateComplexState({
	source: playerId, // Required: target player ID
	args: [{ field: "server update" }],
});
 
// Fire-and-forget mode
void myService.updateComplexState({
	source: playerId,
	args: [{ field: "server update" }],
	noResponse: true,
});
Server→Web Routing

Server→Web calls flow through the client: Server → Client (relay) → Web UI. This adds latency but enables server-driven UI updates.

Sending Events from UI to Client

Use NUI callbacks (fetch to NUI resource):

// UI component
async function handleWithdraw(amount: number) {
	try {
		const response = await fetch("https://true_life/atm:withdraw", {
			method: "POST",
			body: JSON.stringify({ amount }),
		});
		const result = await response.json();
		if (result.success) {
			// Update local state or show notification
		}
	} catch (error) {
		console.error("NUI callback failed:", error);
	}
}

Register the callback on the client:

// src/runtime/client/main.ts
RegisterNuiCallbackType("atm:withdraw");
on("__cfx_nui:atm:withdraw", (data: { amount: number }, cb: (response: unknown) => void) => {
	const result = atmService.withdraw(data.amount);
	cb(result);
});

Page Open/Close Events

For full-screen pages, control visibility:

// Client - open a page
sendHudEvent("page:open", { page: "atm-interface" });
 
// Client - close current page
sendHudEvent("page:close", {});

HUD Group Visibility

Control HUD visibility using groups:

import { setHudGroupVisible } from "@ui/feature";
 
// Hide all default HUDs (e.g., when opening a fullscreen menu)
setHudGroupVisible("default", false);
 
// Show them again
setHudGroupVisible("default", true);

Complete Example: Notifications

Service

// src/modules/notifications/notification.service.ts
import { webOwned } from "@core/observable";
 
interface Notification {
	id: string;
	type: "info" | "success" | "warning" | "error";
	title: string;
	message: string;
	duration: number;
}
 
interface NotificationsState {
	notifications: Notification[];
}
 
export class NotificationService {
	public notificationsState = webOwned<NotificationsState>({
		id: "notifications:state",
		initialValue: { notifications: [] },
	});
 
	pushNotification(notification: Omit<Notification, "id">): void {
		const id = crypto.randomUUID();
		const newNotification = { ...notification, id };
 
		const current = this.notificationsState.value;
		this.notificationsState.set({
			notifications: [...current.notifications, newNotification],
		});
 
		// Auto-remove after duration
		setTimeout(() => {
			this.removeNotification(id);
		}, notification.duration);
	}
 
	removeNotification(id: string): void {
		const current = this.notificationsState.value;
		this.notificationsState.set({
			notifications: current.notifications.filter((n) => n.id !== id),
		});
	}
}

UI Component

// src/modules/notifications/huds/notifications/index.tsx
"use web";
 
import React from "react";
import { useService, useObservable } from "@core/react";
import { NotificationService } from "@modules/features/notifications/notification.service";
 
export default function NotificationsPanel() {
	const notificationService = useService(NotificationService);
	const state = useObservable(notificationService.notificationsState);
 
	const getTypeStyles = (type: string) => {
		const styles: Record<string, string> = {
			info: "bg-blue-500/90 text-white",
			success: "bg-green-500/90 text-white",
			warning: "bg-yellow-500/90 text-black",
			error: "bg-red-500/90 text-white",
		};
		return styles[type] || styles.info;
	};
 
	return (
		<div className="flex w-80 flex-col gap-2">
			{state.notifications.map((notification) => (
				<div
					key={notification.id}
					className={`rounded-lg p-4 ${getTypeStyles(notification.type)} animate-in slide-in-from-right`}
				>
					<h3 className="font-bold">{notification.title}</h3>
					<p className="text-sm">{notification.message}</p>
				</div>
			))}
		</div>
	);
}

Usage from Client

// In any client code
const notificationService = inject(NotificationService);
 
notificationService.pushNotification({
	type: "success",
	title: "Welcome!",
	message: "You have successfully connected.",
	duration: 5000,
});

Migration from sendHudEvent/useNuiEvent

Before (Redux)

// Client
sendHudEvent("needs:update", { hunger: 80, thirst: 90 });
 
// UI
useNuiEvent("needs:update", (data) => {
	dispatch(updateNeeds(data));
});

After (React/Observables)

// Service
public hudState = webOwned<NeedsState>({ /* ... */ });
 
@ClientOnly
updateNeeds(): void {
    this.hudState.set({ hunger: 80, thirst: 90, health: 100, armor: 0 });
}
 
// UI - no event handlers needed!
const state = useObservable(needsService.hudState);
return <div>{state.hunger}%</div>;

Best Practices

  1. Prefer webOwned observables - Simpler and more reactive
  2. Use @Web for complex logic - When you need processing before state update
  3. Use NUI callbacks for UI→Client - For actions like button clicks
  4. Use HUD groups - For fullscreen overlays
  5. Keep payloads small - Only send necessary data
  6. Type your state - Full type coverage for observables