Redux Integration
True Life uses Redux Toolkit with dynamic slice injection for efficient state management.
Store Configuration
The store supports runtime slice injection:
// ui/src/store.ts
import { configureStore, combineReducers, type Reducer } from "@reduxjs/toolkit";
import { featureSlice } from "@ui/state/featureSlice";
export const store = configureStore({
reducer: {
feature: featureSlice.reducer,
},
});
// Dynamic slice injection
const injectedReducers: Record<string, Reducer> = {};
export function injectSlice(name: string, reducer: Reducer): void {
if (!injectedReducers[name]) {
injectedReducers[name] = reducer;
store.replaceReducer(combineReducers({
feature: featureSlice.reducer,
...injectedReducers,
}));
}
}
export function removeSlice(name: string): void {
if (injectedReducers[name]) {
delete injectedReducers[name];
store.replaceReducer(combineReducers({
feature: featureSlice.reducer,
...injectedReducers,
}));
}
}
Creating Slices
Use Redux Toolkit's createSlice:
// src/modules/banking/huds/balance/state/slice.ts
"use web";
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
interface BalanceState {
cash: number;
bank: number;
isLoading: boolean;
}
const initialState: BalanceState = {
cash: 0,
bank: 0,
isLoading: true,
};
export const balanceSlice = createSlice({
name: "balance",
initialState,
reducers: {
setCash: (state, action: PayloadAction<number>) => {
state.cash = action.payload;
},
setBank: (state, action: PayloadAction<number>) => {
state.bank = action.payload;
},
setBalances: (state, action: PayloadAction<{ cash: number; bank: number }>) => {
state.cash = action.payload.cash;
state.bank = action.payload.bank;
state.isLoading = false;
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
},
});
export const { setCash, setBank, setBalances, setLoading } = balanceSlice.actions;
// Selectors
export const selectCash = (state: { balance: BalanceState }) => state.balance.cash;
export const selectBank = (state: { balance: BalanceState }) => state.balance.bank;
export const selectIsLoading = (state: { balance: BalanceState }) => state.balance.isLoading;
export const selectTotal = (state: { balance: BalanceState }) =>
state.balance.cash + state.balance.bank;
export default balanceSlice.reducer;
Using Hooks
useAppDispatch
Typed dispatch hook:
import { useAppDispatch } from "@ui/hooks";
import { setCash } from "@modules/banking/huds/balance/state/slice";
function CashButton() {
const dispatch = useAppDispatch();
return (
<button onClick={() => dispatch(setCash(100))}>
Set Cash to $100
</button>
);
}
useAppSelector
Typed selector hook:
import { useAppSelector } from "@ui/hooks";
import { selectCash, selectBank } from "@modules/banking/huds/balance/state/slice";
function BalanceDisplay() {
const cash = useAppSelector(selectCash);
const bank = useAppSelector(selectBank);
return (
<div>
<div>Cash: ${cash}</div>
<div>Bank: ${bank}</div>
</div>
);
}
useNuiEvent
Receive events from FiveM client:
import { useNuiEvent, useAppDispatch } from "@ui/hooks";
import { setBalances } from "@modules/banking/huds/balance/state/slice";
function BalancePanel() {
const dispatch = useAppDispatch();
useNuiEvent<{ cash: number; bank: number }>("balance:update", (data) => {
dispatch(setBalances(data));
});
// ...
}
Async Actions
Use createAsyncThunk for async operations:
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
interface TransferParams {
amount: number;
toAccount: string;
}
export const transferMoney = createAsyncThunk(
"banking/transfer",
async (params: TransferParams, { rejectWithValue }) => {
try {
const response = await fetch("https://true_life/banking/transfer", {
method: "POST",
body: JSON.stringify(params),
});
return await response.json();
} catch (error) {
return rejectWithValue("Transfer failed");
}
}
);
const bankingSlice = createSlice({
name: "banking",
initialState: {
isTransferring: false,
error: null as string | null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(transferMoney.pending, (state) => {
state.isTransferring = true;
state.error = null;
})
.addCase(transferMoney.fulfilled, (state) => {
state.isTransferring = false;
})
.addCase(transferMoney.rejected, (state, action) => {
state.isTransferring = false;
state.error = action.payload as string;
});
},
});
Selectors with Memoization
Use createSelector for derived data:
import { createSelector } from "@reduxjs/toolkit";
const selectCash = (state: RootState) => state.balance.cash;
const selectBank = (state: RootState) => state.balance.bank;
// Memoized selector - only recalculates when inputs change
export const selectFormattedTotal = createSelector(
[selectCash, selectBank],
(cash, bank) => {
const total = cash + bank;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total);
}
);
Feature Slice Registration
Slices are registered via the feature definition:
export default defineFeature({
panel: BalancePanel,
slices: [
{ name: balanceSlice.name, reducer: balanceSlice.reducer },
// Multiple slices can be registered
{ name: historySlice.name, reducer: historySlice.reducer },
],
});
State Types
Define your root state type:
// ui/src/store.ts
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// In hooks
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export const useAppDispatch = () => useDispatch<AppDispatch>();
Best Practices
- Use slices per feature - Don't share slices across features
- Create selectors - Don't access state directly in components
- Memoize derived data - Use
createSelector - Handle loading states - Track async operation status
- Use Immer patterns - RTK uses Immer, so mutate directly
- Type everything - Full type coverage for state and actions
- Keep state normalized - Avoid nested structures when possible