Skip to main content

React Integration

True Life uses React 19 with the React Compiler and the observable system for efficient, reactive UI state management. This replaces traditional Redux patterns with direct observable subscriptions.

Core Concepts

Why React with React Compiler?

  • Automatic memoization - React Compiler optimizes components without manual useMemo/useCallback
  • Familiar ecosystem - Large community, extensive tooling, and component libraries
  • Standard JSX - Industry-standard syntax
  • React 19 features - Server components, actions, and improved hooks

Why Observables Instead of Redux?

  • Cross-runtime sync - State syncs automatically between web, client, and server
  • Unified patterns - Same observable API across all runtimes
  • Less boilerplate - No actions, reducers, or slice definitions
  • Direct updates - Services can update UI state directly

Hooks

useService

Get a service instance via dependency injection:

import { useService } from "@core/react";
import { NeedsService } from "@modules/core/needs/needs.service";
 
function StatusPanel() {
	const needsService = useService(NeedsService);
 
	// Access service methods and observables
	const handleReset = () => {
		needsService.resetNeeds();
	};
 
	return <button onClick={handleReset}>Reset</button>;
}

Important: useService must be called at the top level of a component, not inside conditionals or loops.

useObservable

Subscribe to an observable with automatic cleanup:

import { useService, useObservable } from "@core/react";
import { NeedsService } from "@modules/core/needs/needs.service";
 
function StatusPanel() {
	const needsService = useService(NeedsService);
 
	// Returns the current value and re-renders when it changes
	const state = useObservable(needsService.hudState);
 
	// Access directly (no function call needed)
	return (
		<div>
			<div>Hunger: {state.hunger}%</div>
			<div>Thirst: {state.thirst}%</div>
		</div>
	);
}

Key points:

  • Returns the current observable value directly
  • Automatically subscribes to observable changes
  • Cleans up subscription when component unmounts
  • Triggers re-render when observable value changes

Creating Components

Basic Component Structure

// src/modules/my-feature/huds/my-hud/index.tsx
"use web";
 
import React from "react";
import { useService, useObservable } from "@core/react";
import { MyService } from "@modules/my-feature/my.service";
 
export default function MyHudPanel() {
	const myService = useService(MyService);
	const state = useObservable(myService.hudState);
 
	return (
		<div className="rounded-lg bg-black/50 p-4 text-white">
			<h2 className="text-lg font-bold">My HUD</h2>
			<div>{state.someValue}</div>
		</div>
	);
}

Handling Events

function InteractivePanel() {
	const notificationService = useService(NotificationService);
 
	const handleClick = () => {
		notificationService.pushNotification({
			type: "info",
			title: "Clicked!",
			message: "Button was clicked",
			duration: 3000,
		});
	};
 
	return (
		<button className="rounded bg-blue-500 px-4 py-2" onClick={handleClick}>
			Click Me
		</button>
	);
}

Conditional Rendering

function ConditionalPanel() {
	const myService = useService(MyService);
	const state = useObservable(myService.hudState);
 
	return (
		<div>
			{/* Standard conditional rendering */}
			{state.isVisible && <div>Visible content</div>}
 
			{/* Ternary for multiple conditions */}
			{state.status === "loading" ? (
				<div>Loading...</div>
			) : state.status === "error" ? (
				<div>Error occurred</div>
			) : (
				<div>Success!</div>
			)}
		</div>
	);
}

List Rendering

function NotificationsList() {
	const notificationService = useService(NotificationService);
	const state = useObservable(notificationService.notificationsState);
 
	return (
		<div className="space-y-2">
			{state.notifications.map((notification) => (
				<div key={notification.id} className="rounded bg-gray-800 p-2">
					<h3>{notification.title}</h3>
					<p>{notification.message}</p>
				</div>
			))}
		</div>
	);
}

Service with webOwned Observable

Services expose webOwned observables for UI state:

// src/modules/needs/needs.service.ts
import { webOwned } from "@core/observable";
 
interface HudState {
	hunger: number;
	thirst: number;
	health: number;
	armor: number;
}
 
export class NeedsService {
	// Web-owned observable - web has R/W, client/server can update
	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 playerHealth = GetEntityHealth(PlayerPedId());
			const playerArmor = GetPedArmour(PlayerPedId());
 
			this.hudState.set({
				...this.hudState.value,
				health: playerHealth,
				armor: playerArmor,
			});
		}, 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,
		});
	}
}

Registering UI Components

UI components are registered in module.ts:

// src/modules/needs/module.ts
import { registerModule } from "@core/module";
import { NeedsService } from "@modules/core/needs/needs.service";
import StatusPanel from "@modules/core/needs/huds/status/index";
 
export default registerModule({
	name: "needs",
	services: [NeedsService],
 
	huds: [
		{
			name: "status",
			panel: StatusPanel,
			hudPosition: "bottom-left",
			hudPriority: -10,
			hudGroup: "default",
		},
	],
});

Differences from Redux

ReduxReact/Observables
useSelector(selectData)useObservable(service.state)
dispatch(action)service.state.set(value)
createSlice()webOwned()
Action creatorsDirect service method calls
ReducersObservable .set() method

Migration Guide

From useAppSelector

// Before (Redux)
const data = useAppSelector(selectData);
return <div>{data.value}</div>;
 
// After (React/Observables)
const myService = useService(MyService);
const data = useObservable(myService.dataState);
return <div>{data.value}</div>;

From useAppDispatch

// Before (Redux)
const dispatch = useAppDispatch();
const handleClick = () => dispatch(updateData(newValue));
 
// After (React/Observables)
const myService = useService(MyService);
const handleClick = () => myService.dataState.set(newValue);

From useNuiEvent

// Before (Redux)
useNuiEvent("my-event", (payload) => {
	dispatch(updateData(payload));
});
 
// After (React/Observables)
// No need! Services update observables directly from client/server
// The UI automatically reacts to observable changes

Best Practices

  1. Use services for all state - Don't create local state for shared data
  2. Call useService at top level - Not inside conditionals or loops
  3. Use map for lists - Standard React pattern with key prop
  4. Use conditional rendering - && or ternary operators
  5. Access state directly - state.value not state().value
  6. Use className - React uses className instead of class
  7. Let React Compiler optimize - Avoid premature useMemo/useCallback