Redux Patterns Guide
This guide covers Redux Toolkit patterns used in Elite Events, including slice structure, typed hooks, selectors, and testing.
Store Structure
src/redux/
├── index.ts # Barrel exports
├── store.ts # Store configuration
├── provider.tsx # React provider
├── features/ # Feature slices
│ ├── cartSlice.ts
│ ├── wishlistSlice.ts
│ └── __tests__/
├── selectors/ # Memoized selectors
│ ├── index.ts
│ ├── cartSelectors.ts
│ └── wishlistSelectors.ts
└── utils/ # Shared utilities
└── createAsyncThunk.ts
Typed Hooks
Always use typed hooks instead of plain useSelector and useDispatch:
// src/redux/store.ts
import { TypedUseSelectorHook, useSelector, useDispatch } from 'react-redux';
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export const useAppDispatch = () => useDispatch<AppDispatch>();
Usage in components:
import { useAppSelector, useAppDispatch } from '@/redux';
import { selectCartItems, addItemToCart } from '@/redux';
function MyComponent() {
const dispatch = useAppDispatch();
const items = useAppSelector(selectCartItems);
const handleAdd = (item: CartItem) => {
dispatch(addItemToCart(item));
};
}
Slice Structure Template
// src/redux/features/myFeatureSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { RootState } from '../store';
// ============================================================================
// TYPES
// ============================================================================
export interface MyItem {
id: number;
name: string;
value: number;
}
interface MyFeatureState {
items: MyItem[];
selectedId: number | null;
loading: boolean;
error: string | null;
}
// ============================================================================
// INITIAL STATE
// ============================================================================
const initialState: MyFeatureState = {
items: [],
selectedId: null,
loading: false,
error: null,
};
// ============================================================================
// ASYNC THUNKS
// ============================================================================
export const fetchItems = createAsyncThunk(
'myFeature/fetchItems',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/items');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : 'Unknown error'
);
}
}
);
export const createItem = createAsyncThunk(
'myFeature/createItem',
async (item: Omit<MyItem, 'id'>, { rejectWithValue }) => {
try {
const response = await fetch('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
if (!response.ok) throw new Error('Failed to create');
return response.json();
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : 'Unknown error'
);
}
}
);
// ============================================================================
// SLICE
// ============================================================================
export const myFeatureSlice = createSlice({
name: 'myFeature',
initialState,
reducers: {
// Synchronous actions
selectItem: (state, action: PayloadAction<number>) => {
state.selectedId = action.payload;
},
clearSelection: (state) => {
state.selectedId = null;
},
updateItem: (state, action: PayloadAction<{ id: number; updates: Partial<MyItem> }>) => {
const item = state.items.find(i => i.id === action.payload.id);
if (item) {
Object.assign(item, action.payload.updates);
}
},
removeItem: (state, action: PayloadAction<number>) => {
state.items = state.items.filter(i => i.id !== action.payload);
},
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
// fetchItems
builder
.addCase(fetchItems.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchItems.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchItems.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
// createItem
builder
.addCase(createItem.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(createItem.fulfilled, (state, action) => {
state.loading = false;
state.items.push(action.payload);
})
.addCase(createItem.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
},
});
// ============================================================================
// ACTIONS EXPORT
// ============================================================================
export const {
selectItem,
clearSelection,
updateItem,
removeItem,
clearError,
} = myFeatureSlice.actions;
// ============================================================================
// SELECTORS
// ============================================================================
export const selectItems = (state: RootState) => state.myFeatureReducer.items;
export const selectLoading = (state: RootState) => state.myFeatureReducer.loading;
export const selectError = (state: RootState) => state.myFeatureReducer.error;
export default myFeatureSlice.reducer;
Memoized Selectors
Use createSelector for derived data to prevent unnecessary re-renders:
// src/redux/selectors/myFeatureSelectors.ts
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../store';
// ============================================================================
// BASE SELECTORS
// ============================================================================
export const selectMyFeatureState = (state: RootState) => state.myFeatureReducer;
export const selectItems = (state: RootState) => state.myFeatureReducer?.items ?? [];
export const selectLoading = (state: RootState) => state.myFeatureReducer?.loading ?? false;
// ============================================================================
// DERIVED SELECTORS
// ============================================================================
/**
* Select total count of items
*/
export const selectItemCount = createSelector(
[selectItems],
(items) => items.length
);
/**
* Select items sorted by name
*/
export const selectItemsSortedByName = createSelector(
[selectItems],
(items) => [...items].sort((a, b) => a.name.localeCompare(b.name))
);
/**
* Select item by ID (parameterized selector)
*/
export const selectItemById = createSelector(
[selectItems, (_state: RootState, itemId: number) => itemId],
(items, itemId) => items.find(item => item.id === itemId)
);
/**
* Select total value
*/
export const selectTotalValue = createSelector(
[selectItems],
(items) => items.reduce((sum, item) => sum + item.value, 0)
);
/**
* Compose multiple selectors
*/
export const selectSummary = createSelector(
[selectItems, selectItemCount, selectTotalValue],
(items, count, total) => ({
items,
count,
total,
average: count > 0 ? total / count : 0,
})
);
Using Parameterized Selectors
// In component
const item = useAppSelector(state => selectItemById(state, productId));
// Or create a bound selector
const selectCurrentItem = useMemo(
() => (state: RootState) => selectItemById(state, productId),
[productId]
);
const item = useAppSelector(selectCurrentItem);
Adding a New Slice
- Create the slice file in
src/redux/features/:
// src/redux/features/notificationsSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
// ... implementation
- Add to store:
// src/redux/store.ts
import notificationsReducer from './features/notificationsSlice';
export const store = configureStore({
reducer: {
// ... existing reducers
notificationsReducer,
},
});
- Create selectors in
src/redux/selectors/:
// src/redux/selectors/notificationsSelectors.ts
import { createSelector } from '@reduxjs/toolkit';
// ... selectors
- Export from index in
src/redux/selectors/index.ts:
export * from './notificationsSelectors';
- Export actions from
src/redux/index.ts:
export {
fetchNotifications,
markAsRead,
// ... other actions
} from './features/notificationsSlice';
Testing Redux
Testing Reducers
// src/redux/features/__tests__/myFeatureSlice.test.ts
import {
myFeatureSlice,
selectItem,
removeItem,
fetchItems,
MyItem,
} from '../myFeatureSlice';
describe('myFeatureSlice', () => {
const initialState = {
items: [],
selectedId: null,
loading: false,
error: null,
};
const mockItem: MyItem = {
id: 1,
name: 'Test Item',
value: 100,
};
describe('reducers', () => {
it('should handle selectItem', () => {
const state = myFeatureSlice.reducer(initialState, selectItem(1));
expect(state.selectedId).toBe(1);
});
it('should handle removeItem', () => {
const stateWithItem = { ...initialState, items: [mockItem] };
const state = myFeatureSlice.reducer(stateWithItem, removeItem(1));
expect(state.items).toHaveLength(0);
});
});
describe('async thunks', () => {
it('should handle fetchItems.pending', () => {
const action = { type: fetchItems.pending.type };
const state = myFeatureSlice.reducer(initialState, action);
expect(state.loading).toBe(true);
expect(state.error).toBeNull();
});
it('should handle fetchItems.fulfilled', () => {
const items = [mockItem];
const action = { type: fetchItems.fulfilled.type, payload: items };
const state = myFeatureSlice.reducer(initialState, action);
expect(state.loading).toBe(false);
expect(state.items).toEqual(items);
});
it('should handle fetchItems.rejected', () => {
const error = 'Failed to fetch';
const action = { type: fetchItems.rejected.type, payload: error };
const state = myFeatureSlice.reducer(initialState, action);
expect(state.loading).toBe(false);
expect(state.error).toBe(error);
});
});
});
Testing Selectors
// src/redux/selectors/__tests__/myFeatureSelectors.test.ts
import {
selectItems,
selectItemCount,
selectTotalValue,
selectItemById,
} from '../myFeatureSelectors';
describe('myFeatureSelectors', () => {
const mockState = {
myFeatureReducer: {
items: [
{ id: 1, name: 'Item 1', value: 100 },
{ id: 2, name: 'Item 2', value: 200 },
],
loading: false,
error: null,
},
};
it('selectItems returns items array', () => {
const result = selectItems(mockState as any);
expect(result).toHaveLength(2);
});
it('selectItemCount returns correct count', () => {
const result = selectItemCount(mockState as any);
expect(result).toBe(2);
});
it('selectTotalValue calculates correctly', () => {
const result = selectTotalValue(mockState as any);
expect(result).toBe(300);
});
it('selectItemById returns correct item', () => {
const result = selectItemById(mockState as any, 1);
expect(result?.name).toBe('Item 1');
});
it('selectItemById returns undefined for non-existent id', () => {
const result = selectItemById(mockState as any, 999);
expect(result).toBeUndefined();
});
});
Testing with Integration (Component + Redux)
// __tests__/MyComponent.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import myFeatureReducer from '@/redux/features/myFeatureSlice';
import { MyComponent } from '../MyComponent';
function renderWithRedux(
component: React.ReactElement,
preloadedState?: Partial<RootState>
) {
const store = configureStore({
reducer: { myFeatureReducer },
preloadedState,
});
return {
...render(<Provider store={store}>{component}</Provider>),
store,
};
}
describe('MyComponent', () => {
it('displays items from store', () => {
renderWithRedux(<MyComponent />, {
myFeatureReducer: {
items: [{ id: 1, name: 'Test', value: 100 }],
loading: false,
error: null,
},
});
expect(screen.getByText('Test')).toBeInTheDocument();
});
it('dispatches action on button click', () => {
const { store } = renderWithRedux(<MyComponent />);
fireEvent.click(screen.getByRole('button', { name: /add/i }));
expect(store.getState().myFeatureReducer.items).toHaveLength(1);
});
});
Best Practices
Do's
- Use typed hooks - Always use
useAppSelectoranduseAppDispatch - Normalize state - Use flat structures with IDs for relational data
- Memoize selectors - Use
createSelectorfor derived data - Handle all thunk states - Always handle pending, fulfilled, and rejected
- Keep slices focused - One slice per feature/domain
- Export from index - Centralize exports in
src/redux/index.ts - Test reducers directly - Unit test reducers without store
Don'ts
- Don't mutate state - RTK uses Immer, but don't return mutated objects
- Don't select in reducers - Reducers should only use action payloads
- Don't dispatch in reducers - Reducers are pure functions
- Don't store derived data - Calculate it in selectors
- Don't store component state - Local UI state stays in components
- Don't over-normalize - Simple data can stay nested
Existing Slices Reference
| Slice | Purpose | Key Actions |
|---|---|---|
cartSlice | Shopping cart state | addItemToCart, removeItemFromCart, fetchCart |
wishlistSlice | Wishlist management | addToWishlistAsync, removeFromWishlistAsync |
notificationsSlice | User notifications | fetchNotifications, markAsRead |
productDetails | Product detail view | updateProductDetails |
quickViewSlice | Quick view modal | openQuickView, closeQuickView |
supportSlice | Support/chat state | toggleChatbot, sendChatbotMessage |
Related Documentation
- Hook Development Guide
- Testing Guide
- Component Guide