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 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
- Prefer webOwned observables - Simpler and more reactive
- Use @Web for complex logic - When you need processing before state update
- Use NUI callbacks for UI→Client - For actions like button clicks
- Use HUD groups - For fullscreen overlays
- Keep payloads small - Only send necessary data
- Type your state - Full type coverage for observables