Some state doesn't come from the server and shouldn't be fetched via HTTP—think theme (light/dark), active filters, sidebar open/closed, currently selected task ID, etc. This is global/app state. For this, Redux Toolkit (RTK) gives you a clean, predictable, and type‑safe pattern.
Quick mental model:
- React state → great for local component-only state.
- Redux (RTK) → great for app-wide UI state that multiple components need.
- TanStack Query → great for server data (caching, refetching, syncing).
🧩 Core Concepts (explained simply)
- Store: a single object tree that holds your app’s global state.
- Slice: a focused piece of state (e.g.,
ui,filters) with its reducers and actions created by RTK. - Reducer: a pure function
(state, action) → newState. In RTK you write mutating-looking code; under the hood Immer produces an immutable update for you. - Action: a plain object
{ type, payload }describing what happened. - Selector: a function that reads/derives data from the store (can be memoized).
- Dispatch: how you send actions to update the store.
0) Install
pnpm add @reduxjs/toolkit react-redux
pnpm add -D @types/react-redux
1) Design the state shape (keep it simple & serializable)
We’ll manage a UI slice that controls theme, modal visibility, and task list filters.
// src/store/uiSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
// 1) Define the TypeScript shape of this slice of state
export type UIState = {
theme: 'light' | 'dark'; // app theme
modalOpen: boolean; // global modal visibility
filter: {
search: string; // text filter for tasks
showDone: boolean; // whether to show completed tasks
};
};
// 2) Initial state — keep it minimal, serializable, and UI-focused
const initialState: UIState = {
theme: 'light',
modalOpen: false,
filter: {
search: '',
showDone: true,
},
};
// 3) Create the slice — RTK generates action creators & action types for you
const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
// Immer lets us "mutate" state safely (it produces an immutable copy)
toggleTheme(state) {
state.theme = state.theme === 'light' ? 'dark' : 'light';
},
setModal(state, action: PayloadAction<boolean>) {
state.modalOpen = action.payload;
},
setSearch(state, action: PayloadAction<string>) {
state.filter.search = action.payload;
},
setShowDone(state, action: PayloadAction<boolean>) {
state.filter.showDone = action.payload;
},
// Example reducer with a structured payload
setFilter(state, action: PayloadAction<{ search?: string; showDone?: boolean }>) {
if (action.payload.search !== undefined) state.filter.search = action.payload.search;
if (action.payload.showDone !== undefined) state.filter.showDone = action.payload.showDone;
},
},
});
// 4) Export actions & reducer
export const { toggleTheme, setModal, setSearch, setShowDone, setFilter } = uiSlice.actions;
export default uiSlice.reducer;
Why this is nice:
- No action type strings to handwrite (RTK does it for you).
PayloadAction<T>gives typed payloads and better DX.- Code looks mutable but stays safely immutable via Immer.
2) Create the store and infer types automatically
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import ui from './uiSlice';
// configureStore sets up Redux DevTools and good defaults (like thunk middleware)
export const store = configureStore({
reducer: { ui },
// middleware: (getDefault) => getDefault(), // customize here if needed
// devTools: process.env.NODE_ENV !== 'production', // optional explicit toggle
});
// 🔹 Inferred types from the store itself — no duplication
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Tip: Avoid writing these types manually; always infer from the created store.
3) Typed hooks (avoid re-typing in every component)
// src/store/hooks.ts
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './index';
// Use these across your app for correct types automatically
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
4) Provide the store to React
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from '@/store';
import AppRouter from '@/app/AppRouter';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<AppRouter />
</Provider>
</React.StrictMode>,
);
5) Selectors (read from the store) and memoized selectors
// src/store/selectors.ts
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from './index';
// Basic selectors (simple reads)
export const selectTheme = (s: RootState) => s.ui.theme;
export const selectModalOpen = (s: RootState) => s.ui.modalOpen;
export const selectFilter = (s: RootState) => s.ui.filter;
// Memoized derived selector (example):
// If you had tasks in Redux, you could filter them by the current UI filter.
// Here we just show the pattern — replace `s.tasks.items` with your real selector.
export const selectVisibleTasks = createSelector(
[(s: RootState) => (s as any).tasks?.items ?? [], selectFilter],
(tasks, filter) => {
const search = filter.search.toLowerCase();
return tasks.filter((t: any) => {
const matchesText = t.title.toLowerCase().includes(search);
const matchesDone = filter.showDone || !t.done;
return matchesText && matchesDone;
});
},
);
Use memoized selectors when computing derived data to avoid unnecessary re-renders.
6) Using Redux in components (with comments)
// src/components/ThemeToggle.tsx
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { toggleTheme } from '@/store/uiSlice';
export function ThemeToggle() {
const dispatch = useAppDispatch();
const theme = useAppSelector((s) => s.ui.theme); // read from store
return (
<button
className="border px-3 py-1 rounded"
onClick={() => dispatch(toggleTheme())} // send action to store
>
Current: {theme} (click to toggle)
</button>
);
}
// src/components/FilterBar.tsx
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setFilter } from '@/store/uiSlice';
export function FilterBar() {
const d = useAppDispatch();
const filter = useAppSelector((s) => s.ui.filter);
return (
<div className="flex gap-3 items-center">
<input
className="border rounded px-2 py-1"
placeholder="Search tasks…"
value={filter.search}
onChange={(e) => d(setFilter({ search: e.target.value }))}
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={filter.showDone}
onChange={(e) => d(setFilter({ showDone: e.target.checked }))}
/>
Show completed
</label>
</div>
);
}
// src/components/Modal.tsx
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModal } from '@/store/uiSlice';
export function Modal() {
const dispatch = useAppDispatch();
const open = useAppSelector((s) => s.ui.modalOpen);
if (!open) return null; // render nothing when closed
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-white p-6 rounded shadow-lg">
<p>This is a global modal controlled by Redux.</p>
<button className="mt-3" onClick={() => dispatch(setModal(false))}>Close</button>
</div>
</div>
);
}
7) Middleware, DevTools, and good practices
- DevTools:
configureStoreenables Redux DevTools in dev by default—inspect actions/state timeline. - Middleware: use
middleware: (getDefault) => getDefault().concat(myMiddleware)for logging/analytics. - Serializable state: keep non‑serializable values out of Redux (e.g., don’t store DOM nodes, class instances, or Promises). Use IDs instead.
- Normalization: if you store large collections, normalize by ID (RTK has
createEntityAdapterfor this). - Colocation: only move state to Redux when multiple distant components need it. Don’t lift state “just because.”
8) When Redux vs React Query?
- Use Redux for client/app UI state (theme, layout, filters, wizard steps, drawer open state, selected IDs, temporary UI preferences).
- Use React Query for server state (data fetched from APIs—caching, refetching, background sync, mutations).
- It’s normal—and recommended—to use both in one app, each for what it’s best at.
✅ Wrap‑Up
You built a fully typed Redux Toolkit setup:
- Clear state shape (
UIState) + reducers using Immer - Slices that auto‑generate action creators
- A configured store with inferred
RootState/AppDispatch - Typed hooks (
useAppSelector,useAppDispatch) - Real world components (ThemeToggle, FilterBar, Modal) wired end‑to‑end
Next up you can extend the pattern with createEntityAdapter for normalized lists or add persistence with redux-persist (optional). If you’re good here, we can move on to Context API & Dependency Injection for lightweight, slice‑like scoping without Redux.
Comments
Post a Comment