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