As React applications grow in scope and complexity, managing shared state across components can become increasingly difficult. This is where Redux enters as a React state management solution – it offers a centralized store for state, making it easier to access data predictably across the app. In this guide, we’ll walk through practical examples for implementing Redux to handle React state management in large React apps.

Introducing Redux

Redux provides a single source of truth for application state, accessible by all components. It uses a “one-way data flow” model, meaning state is read-only and changes are made by dispatching actions that describe state modifications. This predictable flow enables easier debugging, testing, and organization.

Key Redux concepts:

  • Store – Holds entire app state object
  • Actions – Plain objects describing state changes, like {type: 'ADD_TODO', text: 'Learn Redux’}
  • Reducers – Pure functions taking state/action as input, outputting new state

For example, a reducer handling todo additions:

interface Todo {
  id: number;
  text: string;
  completed: boolean; 
}

interface AddTodoAction {
  type: 'ADD_TODO';
  text: string;
}

function todosReducer(state = [], action: AddTodoAction) {
  switch(action.type) {
    case 'ADD_TODO':
      return [...state, {
        id: nextTodoId++,
        text: action.text,
        completed: false
      }];
    default: 
      return state;
  }
}

This is the basics – now let’s implement Redux into a React app.

Connecting Store to React with react-redux

The official react-redux bindings connect the Redux store to React components. The<Provider>component makes the store available to nested components via context. Calling useSelector hooks (or connect for class components) lets you extract state values. Calling useDispatch gives access to the dispatch function.

import { Provider } from 'react-redux';

// Pass store to <Provider>
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

Components can now access the store – for example, getting todo state:

import { useSelector } from 'react-redux';

export function TodoList() {
  const todos = useSelector(state => state.todos); // Get todos state
  
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo}/>
      ))}
    </ul>
  );
}

Mutable vs Immutable State

A key Redux principle is that state is read-only – direct mutations are prohibited. For example, this edits state directly ❌:

// Direct state mutation NOT allowed
function todosReducer(state, action) {
  switch(action.type) {
    case 'COMPLETE_TODO': 
      state[action.index].completed = true; // Manipulating existing object ❌
      return state;
  }
}

Instead, reducers must return new state objects. For example, returning a copied array with the updated todo ✅:

// Must return new state object instead 
function todosReducer(state, action) {
  switch(action.type) {
    case 'COMPLETE_TODO':
      return state.map(todo => {
        if(todo.id !== action.id) return todo;
        
        // Copy todo + toggle completed 
        return {...todo, completed : !todo.completed}; 
      });
  } 
}

For large state management, immutability libraries like Immer can simplify immutable updates.

Structuring Redux State and Files

Well-structured Redux state enables easier access and understanding across growing codebases. Some suggestions:

  • Organize domains into sub-states at root level (e.g. {users, posts, comments}) rather than nesting
  • Extract reducer logic into separate slice files export const todosReducer = …
  • Export pre-built action creators for consistency
  • Normalize data (avoid nested duplication)

An example state shape:

{
  todos: [{
    id: 1,
    text: 'Learn Redux', 
    completed: true
  }],
  users: {
    // Normalized by ID 
    byId: {
      54: {
        name: 'John'
      }
    }
  }
}

Example file structure:

redux
├── actions
│ ├── index.ts 
│ ├── posts.ts
│ └── todos.ts
├── reducers
│ ├── index.ts
│ ├── posts.ts
│ └── todos.ts
├── store.ts
└── types.ts

Implementing Async Logic

Redux itself only handles synchronous data flow. For async logic like fetching data, we need middleware like Redux Thunk or Redux Saga.

Redux Thunk allows passing functions to dispatch. Thunk functions can dispatch multiple times, enabling async requests:

// Async thunk action creator
export function fetchTodos() { 
  return async (dispatch) => {
    dispatch({type: 'LOADING_TODOS'});
    
    try {
      const response = await client.get('/todos');
      
      dispatch({type: 'FETCH_TODOS_SUCCESS', todos: response.todos});
      
    } catch(err) {
      dispatch({type: 'FETCH_TODOS_ERROR'}); 
    }
  }
}

// Usage:
store.dispatch(fetchTodos());

Redux Saga uses generator functions that act like background threads to manage side effects.

Example fetching data with Sagas:

// Fetches todos from api 
function* fetchTodosSaga() {
  yield takeEvery('FETCH_TODOS', fetchTodosFromApi); 
}

// Helper worker saga 
function* fetchTodosFromApi() {
  try { 
    const todos = yield call(api.fetchTodos);
    yield put({type: 'FETCH_TODOS_SUCCESS', todos});
    
  } catch (error) {
    yield put({type: 'FETCH_TODOS_ERROR'});
  }
}

Sagas offer more advanced flows but require familiarity with generators. Thunks are typically easier to start.

Optimizing Performance

Optimizing performance becomes important as Redux apps grow. Some best practices:

  • Avoid unnecessary rerenders by using React.memo on components
  • Make selectors efficient by caching, slicing state properly
  • Disable React DevTools tracking in production builds
  • Use production-optimized Redux store with applyMiddleware
  • Structure reducers appropriately to minimize nesting

Following React performance best practices also applies to Redux apps. Analyze bottlenecks as needed with browser dev tools profiling.

Conclusion

Redux provides invaluable React state management for complex React applications. With its centralized store, predictable data flow, and organization conventions, Redux can simplify data flows across growing codebases.

Implementing Redux best practices around immutable state, structure, asynchronous logic and performance considerations will enable smooth sailing as apps scale up. The concepts are easy to understand but take time to apply properly – immutability especially trips up beginners. Stick with it, lean on the thriving community, and Redux may prove a vital ally in wrangling state as your React apps grow.

Reach Out to the Experts

If you require assistance on a React + Redux project or are seeking long-term frontend development, Contact Me today. I specialize in building, high-performance React apps backed by excellent React state management and overall architecture. I would love to discuss your needs and see how I can help bring your ideas to life.