Skip to main content

UI Features

Features are the building blocks of True Life's UI system. They encapsulate components, state, and lifecycle hooks.

Feature Definition

Features are defined using defineFeature():

// src/modules/my-feature/huds/my-hud/feature.tsx
"use web";

import { defineFeature } from "@ui/feature";
import Panel from "@modules/my-feature/huds/my-hud/Panel";
import reducer, { mySlice } from "@modules/my-feature/huds/my-hud/state/slice";

export default defineFeature({
// React component to render
panel: Panel,

// Redux slices to inject when feature loads
slices: [{ name: mySlice.name, reducer }],

// For HUD features: grid position
hudPosition: "bottom-left",

// For HUD features: render priority (lower = renders first)
hudPriority: -10,

// Optional: called after slices injected, before rendering
preload: async () => {
// Load initial data
},

// Optional: called when feature unloads
teardown: async () => {
// Cleanup
},
});

HUD Positions

HUDs are positioned in a 3x3 grid:

PositionUse Case
top-leftMinimap, time
top-centerNotifications
top-rightMoney, inventory quick-view
center-leftQuest trackers
center-centerModal dialogs
center-rightVehicle HUD
bottom-leftHealth, needs
bottom-centerSpeedometer
bottom-rightRadio, voice

HUD Priority

When multiple HUDs share a position, hudPriority determines stacking order:

// Renders first (bottom of stack)
hudPriority: -100,

// Renders last (top of stack)
hudPriority: 100,

Creating a HUD Feature

1. Create Directory Structure

src/modules/my-feature/huds/my-hud/
├── feature.tsx
├── Panel.tsx
└── state/
└── slice.ts

2. Create Redux Slice

// src/modules/my-feature/huds/my-hud/state/slice.ts
"use web";

import { createSlice, type PayloadAction } from "@reduxjs/toolkit";

interface MyHudState {
value: number;
label: string;
}

const initialState: MyHudState = {
value: 0,
label: "",
};

export const myHudSlice = createSlice({
name: "myHud",
initialState,
reducers: {
setValue: (state, action: PayloadAction<number>) => {
state.value = action.payload;
},
setLabel: (state, action: PayloadAction<string>) => {
state.label = action.payload;
},
update: (state, action: PayloadAction<Partial<MyHudState>>) => {
Object.assign(state, action.payload);
},
},
});

export const { setValue, setLabel, update } = myHudSlice.actions;

// Selectors
export const selectValue = (state: { myHud: MyHudState }) => state.myHud.value;
export const selectLabel = (state: { myHud: MyHudState }) => state.myHud.label;

export default myHudSlice.reducer;

3. Create Panel Component

// src/modules/my-feature/huds/my-hud/Panel.tsx
"use web";

import { useAppSelector, useNuiEvent, useAppDispatch } from "@ui/hooks";
import { selectValue, selectLabel, update } from "@modules/my-feature/huds/my-hud/state/slice";

export default function Panel() {
const dispatch = useAppDispatch();
const value = useAppSelector(selectValue);
const label = useAppSelector(selectLabel);

// Receive events from FiveM client
useNuiEvent<{ value: number; label: string }>("my-hud:update", (data) => {
dispatch(update(data));
});

return (
<div className="rounded-lg bg-black/80 p-4 backdrop-blur">
<div className="text-sm text-gray-400">{label}</div>
<div className="text-2xl font-bold text-white">{value}</div>
</div>
);
}

4. Create Feature Definition

// src/modules/my-feature/huds/my-hud/feature.tsx
"use web";

import { defineFeature } from "@ui/feature";
import Panel from "@modules/my-feature/huds/my-hud/Panel";
import reducer, { myHudSlice } from "@modules/my-feature/huds/my-hud/state/slice";

export default defineFeature({
panel: Panel,
slices: [{ name: myHudSlice.name, reducer }],
hudPosition: "bottom-left",
hudPriority: 0,
});

5. Register in Module

// src/modules/my-feature/module.ts
import { registerModule } from "@core/module";

export default registerModule({
name: "my-feature",
huds: {
"my-hud": () => import("@modules/my-feature/huds/my-hud/feature"),
},
});

Creating a Page Feature

Pages follow the same pattern but without positioning:

// src/modules/atm/pages/atm-interface/feature.tsx
"use web";

import { defineFeature } from "@ui/feature";
import Page from "@modules/atm/pages/atm-interface/Page";
import reducer, { atmSlice } from "@modules/atm/pages/atm-interface/state/slice";

export default defineFeature({
panel: Page,
slices: [{ name: atmSlice.name, reducer }],
// No hudPosition = page feature
});

Lifecycle Hooks

preload

Called after slices are injected, before rendering:

preload: async () => {
// Fetch initial data
const data = await fetchInitialData();
store.dispatch(setInitialData(data));
},

teardown

Called when the feature is unloaded:

teardown: async () => {
// Cleanup subscriptions
subscription.unsubscribe();

// Clear state
store.dispatch(clearState());
},

Feature Loading

Features are loaded dynamically when needed:

// Load a feature
await dispatch(loadFeature("my-hud"));

// Unload a feature
await dispatch(unloadFeature("my-hud"));

The feature system automatically:

  1. Dynamically imports the feature module
  2. Injects Redux slices
  3. Calls preload hook
  4. Renders the component
  5. On unload, calls teardown and removes slices

Best Practices

  1. Use "use web" directive - All feature files need it
  2. Keep components focused - Single responsibility
  3. Use selectors - Don't access state directly
  4. Handle loading states - Show skeletons/loaders
  5. Clean up in teardown - Prevent memory leaks
  6. Use path aliases - Consistent imports