ποΈ Redux Pattern
Custom convention used across station + mobile: slices contain only sync reducers, thunks dispatch lifecycle actions explicitly. We don't use createAsyncThunk lifecycle in extraReducers (the standard RTK pattern).
Why custom? Explicit dispatch keeps loading/error state under direct control of the thunk body β easier to reason about optimistic updates, retries, and conditional success dispatches without scattering logic across extraReducers cases.
Folder Structureβ
store/
{domain}/
{domain}.slice.ts β createSlice: state + sync reducers only
{domain}.actions.ts β async thunks (createAppAsyncThunk) + WS event actions (createAction)
{domain}.types.ts β state type + payload/param types
helpers.ts β createAppAsyncThunk, withToast, withLoading, makeActionCreator, createSliceHook
hooks.ts β useAppDispatch, useAppSelector
store.ts
Lifecycleβ
Slice Fileβ
// devices.slice.ts
const { actReq, actMutate, actReject } = makeActionCreator<DevicesState>();
const devicesSlice = createSlice({
name: 'devices',
initialState,
reducers: {
fetchRequest: actReq('isLoading'),
fetchSuccess: actMutate<Device[]>((devices, state) => {
state.isLoading = false;
state.items = {};
for (const d of devices) state.items[d.id] = d;
}),
fetchFailure: actReject<string>('isLoading'),
reset: () => initialState,
},
extraReducers: (builder) => {
builder
.addCase(deviceStateChanged, (state, action) => {
/* WS event */
})
.addCase(wsConnectionChanged, (state, action) => {
/* connection event */
});
},
});
export const devicesActions = devicesSlice.actions;
export const devicesReducer = devicesSlice.reducer;
extraReducers is allowed only for cross-slice WS event actions and wsConnectionChanged β not for thunk lifecycle.
Actions Fileβ
// devices.actions.ts
import { createAction } from '@reduxjs/toolkit';
import { createAppAsyncThunk, withLoading, withToast } from '@/store/helpers';
// WS event actions (consumed by extraReducers in slice)
export const deviceStateChanged = createAction<DeviceStateMsg>('devices/deviceStateChanged');
// Async thunks β dispatch slice actions explicitly
export const fetchDevicesThunk = createAppAsyncThunk(
'devices/fetch',
withLoading(devicesActions, async ({ stationId }: { stationId: string }, { dispatch }) => {
const data = await api.getDevices(stationId);
dispatch(devicesActions.fetchSuccess(data));
}),
);
export const sendCommandThunk = createAppAsyncThunk(
'devices/sendCommand',
async (params: SendCommandParams, { dispatch }) => {
try {
await withToast(() => api.sendCommand(params.deviceId, params.stationId, params.command));
} catch (e) {
dispatch(devicesActions.clearOptimistic({ deviceId: params.deviceId }));
throw e;
}
},
);
// Slice hook β exported from actions.ts (not slice.ts) since it includes thunks
export const useDevicesSlice = createSliceHook('devices', {
...devicesActions,
fetchDevicesThunk,
sendCommandThunk,
});
Helpers (store/helpers.ts)β
createAppAsyncThunkβ
Typed wrapper around createAsyncThunk:
const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState;
dispatch: AppDispatch;
}>();
withLoading(actions, fn)β
Wraps the thunk body β auto-dispatches fetchRequest() before, fetchFailure(error) on throw. The success dispatch is explicit inside fn.
export const fetchDevicesThunk = createAppAsyncThunk(
'devices/fetch',
withLoading(devicesActions, async ({ stationId }, { dispatch }) => {
const data = await api.getDevices(stationId);
dispatch(devicesActions.fetchSuccess(data));
}),
);
Requires the slice to have fetchRequest (no payload) and fetchFailure(error: string).
withToast(fn, opts)β
Shows toast.success / toast.error, always re-throws:
const result = await withToast(() => api.updateDevice(id, payload), {
success: i18next.t('devices.updated'),
errorMessage: (err) =>
err instanceof HttpError && err.statusCode === 404
? i18next.t('devices.notFound')
: undefined, // undefined β falls back to err.message
});
makeActionCreator<S>()β
Generates type-safe sync reducer factories for a slice's state S:
const { actReq, actMutate, actReject, actResolve, act, actEmpty } = makeActionCreator<MyState>();
actReq('isLoading'); // sets isLoading=true, no payload
actMutate<P>((payload, draft)); // immer mutation
actReject<string>('isLoading'); // sets isLoading=false, error=payload
actResolve<P>('data', 'isLoading'); // sets data=payload + isLoading=false
act<P>((payload, state) => Partial<S>); // returns new partial state (no immer)
actEmpty<P>(); // no-op reducer (passthrough)
actReq deliberately takes no generic β it produces an ActionCreatorWithoutPayload, important for components that fire actions.fetchRequest() with no args.
createSliceHook(name, actions)β
Type-safe (state, actions) selector hook. Must be exported from *.actions.ts (not .slice.ts) because it includes thunks alongside sync actions.
// single value + one action
const [isRestoring, restoreSession] = useAuthSlice((s, a) => [s.isRestoring, a.restoreSession]);
// multiple values + one action
const [stations, isLoading, fetchStations] = useStationsSlice((s, a) => [
s.order.map((id) => s.items[id]).filter(Boolean),
s.isLoading,
a.fetchStations,
]);
// many actions β pass the whole `a`
const [members, invites, isLoading, actions] = useMembersSlice((s, a) => [
s.members,
s.invites,
s.isLoading,
a,
]);
actions.fetchMembersThunk(stationId);
// calling bound thunks β .unwrap() works
await resetPasswordThunk({ token, newPassword }).unwrap();
Prefer createSliceHook over raw useAppDispatch. If a domain has no meaningful state, create a minimal slice with empty reducers so the hook still works.
WS Event Action Conventionsβ
WS-driven actions and command-confirmation actions are different categories β naming makes this explicit:
Namingβ
- Wire events (action originates from incoming WS message): prefix
ws*. Example:wsDeviceDeleted,wsDeviceStateChanged. The prefix marks origin: another tab/station/user produced this. - Sync confirmations (action dispatched by our own thunk after a successful API mutation): plain past tense, no prefix. Example:
zoneCreated,deviceRemoved.
The same domain can have both. e.g. devices: deviceRemoved after our DELETE /devices/:id succeeds, AND wsDeviceDeleted when another client deletes a device. Both reducers may do the same state mutation; the names disambiguate the origin.
This intentionally diverges from smart-home-mobile, which uses unprefixed past-tense for both categories. Station prioritizes explicit wire/sync separation.
Locationβ
- Wire events are registered in
ws/wsActionRegistry.tsβ, dispatched byws.middleware.ts. - The
createActionlives in the OWNING slice's*.actions.tsonce the slice is migrated to v2. Temporarily inws/ws.actions.tsif not yet migrated.
Cross-Slice Resetβ
Slices expose reset: () => initialState. Logout / delete-account thunks dispatch all resets explicitly:
dispatch(authActions.reset());
dispatch(devicesActions.reset());
dispatch(deviceTypesActions.reset());
dispatch(membersActions.reset());
dispatch(stationsActions.reset());
Do not use extraReducers listening to logoutThunk.fulfilled β ownership stays with the auth actions file.
Transport Layerβ
// transport/http/devices.ts
import { apiClient } from './client';
export async function getDevices(stationId: string): Promise<Device[]> {
const { data } = await apiClient.get<Device[]>(`/devices?stationId=${stationId}`);
return data;
}
- Named async arrow exports
- Use
apiClientfromclient.ts(axios with auth interceptors + token refresh) - Type response with generics, return
datadirectly
Re-exporting transport types β if a screen needs a transport-layer type (InviteInfo), re-export it from the actions file:
// invites.actions.ts
export type { InviteInfo } from '@/transport/http/invites';
The screen imports from @/store/invites/invites.actions, never from @/transport/.