Skip to main content
Back to Elite Events

Elite Events Documentation

Technical documentation, guides, and API references for the Elite Events platform.

State Management/Redux Patterns

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

  1. Create the slice file in src/redux/features/:
// src/redux/features/notificationsSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
// ... implementation
  1. Add to store:
// src/redux/store.ts
import notificationsReducer from './features/notificationsSlice';

export const store = configureStore({
  reducer: {
    // ... existing reducers
    notificationsReducer,
  },
});
  1. Create selectors in src/redux/selectors/:
// src/redux/selectors/notificationsSelectors.ts
import { createSelector } from '@reduxjs/toolkit';
// ... selectors
  1. Export from index in src/redux/selectors/index.ts:
export * from './notificationsSelectors';
  1. 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 useAppSelector and useAppDispatch
  • Normalize state - Use flat structures with IDs for relational data
  • Memoize selectors - Use createSelector for 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

SlicePurposeKey Actions
cartSliceShopping cart stateaddItemToCart, removeItemFromCart, fetchCart
wishlistSliceWishlist managementaddToWishlistAsync, removeFromWishlistAsync
notificationsSliceUser notificationsfetchNotifications, markAsRead
productDetailsProduct detail viewupdateProductDetails
quickViewSliceQuick view modalopenQuickView, closeQuickView
supportSliceSupport/chat statetoggleChatbot, sendChatbotMessage
Documentation | Elite Events | Philip Rehberger