REDUX, REDUX TOOLKIT, STATE MANAGEMENT - rs-hash/Senior GitHub Wiki

REDUX, REDUX TOOLKIT, STATE MANAGEMENT, Immutable.js, Thunk, saga

REDUX

  • a single centralized place to contain the global state in your application, and specific patterns to follow when updating that state to make the code predictable.
  • Redux is a JS library for predictable and maintainable global state management.
  • Redux is a pattern and library for managing and updating application state, using events called "actions". It serves as a centralized store for state that needs to be used across your entire application, with rules ensuring that the state can only be updated in a predictable fashion.

REDUX CONCEPTS

  • Redux is a library for managing global application state

  • Redux is typically used with the React-Redux library for integrating Redux and React together
  • Redux Toolkit is the recommended way to write Redux logic
  • Redux uses a "one-way data flow" app structure

  • State describes the condition of the app at a point in time, and UI renders based on that state
  • When something happens in the app:
  • The UI dispatches an action
  • The store runs the reducers, and the state is updated based on what occurred
  • The store notifies the UI that the state has changed
  • The UI re-renders based on the new state
  • Redux uses several types of code

  • Actions are plain objects with a type field, and describe "what happened" in the app
  • Reducers are functions that calculate a new state value based on previous state + an action
  • A Redux store runs the root reducer whenever an action is dispatched

REDUX EXAMPLE

Step-by-Step Breakdown

  • User clicks a button: This triggers an action dispatch.
  • Action is dispatched to the Redux store: The store receives the action.
  • Store runs the reducer function: The reducer processes the action and updates the state.
  • Store notifies subscribed UI components: The UI updates based on the new state.
/*store.js*/

import { createStore } from 'redux';

const initialState = {
  count: 0,
};

function counterReducer(state = initialState, action) {
  switch (action.type) {
    case 'counter/increment':
      return { count: state.count + 1 };
    default:
      return state;
  }
}

const store = createStore(counterReducer);
export default store;


/*Counter.jsx*/

import React from 'react';
import { useSelector, useDispatch } from 'react-redux';

const Counter = () => {
  const count = useSelector((state) => state.count);
  const dispatch = useDispatch();

  const handleIncrement = () => {
    dispatch({ type: 'counter/increment' });
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  );
};

export default Counter;

/*App.jsx*/

import React from 'react';
import { Provider } from 'react-redux';
import store from './store';
import Counter from './Counter';

const App = () => {
  return (
    <Provider store={store}>
      <Counter />
    </Provider>
  );
};

export default App;

REDUX TOOLKIT

The Redux Toolkit package is intended to be the standard way to write Redux logic.

REDUX TOOLKIT CONCEPTS

We can create a Redux store using the Redux Toolkit configureStore API

  • configureStore accepts a reducer function as a named argument
  • configureStore automatically sets up the store with good default settings

Redux logic is typically organized into files called "slices"

  • A "slice" contains the reducer logic and actions related to a specific feature / section of the Redux state
  • Redux Toolkit's createSlice API generates action creators and action types for each individual reducer function you provide

Redux reducers must follow specific rules

  • Should only calculate a new state value based on the state and action arguments
  • Must make immutable updates by copying the existing state
  • Cannot contain any asynchronous logic or other "side effects"
  • Redux Toolkit's createSlice API uses Immer to allow "mutating" immutable updates

Async logic is typically written in special functions called "thunks"

  • Thunks receive dispatch and getState as arguments
  • Redux Toolkit enables the redux-thunk middleware by default

React-Redux allows React components to interact with a Redux store

  • Wrapping the app with <Provider store={store}> enables all components to use the store
  • Global state should go in the Redux store, local state should stay in React components

REDUX TOOLKIT EXAMPLE

/src

  • index.js: the starting point for the app
  • App.js: the top-level React component
  • /app
  • store.js: creates the Redux store instance
  • /features
  • /counter
  • Counter.js: a React component that shows the UI for the counter feature
  • counterSlice.js: the Redux logic for the counter feature
  • npm install @reduxjs/toolkit

1. Creating the Redux Store

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

export default configureStore({
  reducer: {
    counter: counterReducer,
  },
});
  • The Redux store is created using the configureStore function from Redux Toolkit. configureStore requires that we pass in a reducer argument.

  • Our application might be made up of many different features, and each of those features might have its own reducer function. When we call configureStore, we can pass in all of the different reducers in an object. The key names in the object will define the keys in our final state value.

  • We have a file named features/counter/counterSlice.js that exports a reducer function for the counter logic. We can import that counterReducer function here, and include it when we create the store.

  • When we pass in an object like {counter: counterReducer}, that says that we want to have a state.counter section of our Redux state object, and that we want the counterReducer function to be in charge of deciding if and how to update the state.counter section whenever an action is dispatched.

2. Creating Slice Reducers and Actions

import { createSlice } from '@reduxjs/toolkit'

export const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0
  },
  reducers: {
    increment: state => {
      // Redux Toolkit allows us to write "mutating" logic in reducers. It
      // doesn't actually mutate the state because it uses the immer library,
      // which detects changes to a "draft state" and produces a brand new
      // immutable state based off those changes
      state.value += 1
    },
    decrement: state => {
      state.value -= 1
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload
    }
  }
})

export const { increment, decrement, incrementByAmount } = counterSlice.actions

export default counterSlice.reducer
  • createSlice automatically generates action creators with the same names as the reducer functions we wrote. We can check that by calling one of them and seeing what it return
  • createSlice uses a library called Immer inside. Immer uses a special JS tool called a Proxy to wrap the data you provide, and lets you write code that "mutates" that wrapped data. But, Immer tracks all the changes you've tried to make, and then uses that list of changes to return a safely immutably updated value, as if you'd written all the immutable update logic by hand.
console.log(counterSlice.actions.increment())
// {type: "counter/increment"}

3. Writing Async logic with Thunk

A thunk is a specific kind of Redux function that can contain asynchronous logic. Thunks are written using two functions:

  • An inside thunk function, which gets dispatch and getState as arguments
  • The outside creator function, which creates and returns the thunk function
  • However, using thunks requires that the redux-thunk middleware (a type of plugin for Redux) be added to the Redux store when it's created.
  • Fortunately, Redux Toolkit's configureStore function already sets that up for us automatically, so we can go ahead and use thunks here.
// The function below is called a thunk and allows us to perform async logic.
// It can be dispatched like a regular action: `dispatch(incrementAsync(10))`.
// This will call the thunk with the `dispatch` function as the first argument.
// Async code can then be executed and other actions can be dispatched
export const incrementAsync = amount => dispatch => {
  setTimeout(() => {
    dispatch(incrementByAmount(amount))
  }, 1000)
}

//We can use them the same way we use a typical Redux action creator:
store.dispatch(incrementAsync(5))

counterSlice.jsx

import { createSlice } from '@reduxjs/toolkit'

export const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
  },
  reducers: {
    increment: (state) => {
      // Redux Toolkit allows us to write "mutating" logic in reducers. It
      // doesn't actually mutate the state because it uses the immer library,
      // which detects changes to a "draft state" and produces a brand new
      // immutable state based off those changes
      state.value += 1
    },
    decrement: (state) => {
      state.value -= 1
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload
    },
  },
})

export const { increment, decrement, incrementByAmount } = counterSlice.actions

// The function below is called a thunk and allows us to perform async logic. It
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
// will call the thunk with the `dispatch` function as the first argument. Async
// code can then be executed and other actions can be dispatched
export const incrementAsync = (amount) => (dispatch) => {
  setTimeout(() => {
    dispatch(incrementByAmount(amount))
  }, 1000)
}

// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they're used instead of
// in the slice file. For example: `useSelector((state) => state.counter.value)`
export const selectCount = (state) => state.counter.value

export default counterSlice.reducer

4. The React Counter Component

import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
  decrement,
  increment,
  incrementByAmount,
  incrementAsync,
  selectCount,
} from './counterSlice';
import styles from './Counter.module.css';

export function Counter() {
  const count = useSelector(selectCount);
  const dispatch = useDispatch();
  const [incrementAmount, setIncrementAmount] = useState('2');

  return (
    <div>
      <div className={styles.row}>
        <button
          className={styles.button}
          aria-label="Increment value"
          onClick={() => dispatch(increment())}
        >
          +
        </button>
        <span className={styles.value}>{count}</span>
        <button
          className={styles.button}
          aria-label="Decrement value"
          onClick={() => dispatch(decrement())}
        >
          -
        </button>
      </div>
      <div className={styles.row}>
        <input
          className={styles.textbox}
          aria-label="Set increment amount"
          value={incrementAmount}
          onChange={e => setIncrementAmount(e.target.value)}
        />
        <button
          className={styles.button}
          onClick={() =>
            dispatch(incrementByAmount(Number(incrementAmount) || 0))
          }
        >
          Add Amount
        </button>
        <button
          className={styles.asyncButton}
          onClick={() => dispatch(incrementAsync(Number(incrementAmount) || 0))}
        >
          Add Async
        </button>
      </div>
    </div>
  );
}

Use of Immer.js in Redux Toolkit

  • Redux Toolkit (RTK) integrates Immer.js internally. When you create slices and reducers using RTK's createSlice or createReducer, it uses Immer.js to allow writing reducers that "mutate" the state, but under the hood, these mutations result in immutably updated state.

Example with Redux Toolkit:

import { createSlice, configureStore } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => {
      state.value += 1; // Looks like a mutation, but Immer makes it immutable
    },
    decrement: (state) => {
      state.value -= 1;
    },
  },
});

const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
  },
});

store.dispatch(counterSlice.actions.increment());
console.log(store.getState().counter.value); // 1

createAsyncThunk

  • createAsyncThunk is a utility provided by Redux Toolkit to handle asynchronous operations. It helps to create thunks that automatically dispatch actions based on the lifecycle of a promise (pending, fulfilled, and rejected).

Steps to Use createAsyncThunk for Fetching API Data in Redux Toolkit

  • Create an Async Thunk
  • Create a Slice
  • Configure the Store
  • Use the Thunk in a Component
// features/posts/postsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';

// Define the async thunk
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
  const response = await axios.get('https://jsonplaceholder.typicode.com/posts');
  return response.data;
});

const postsSlice = createSlice({
  name: 'posts',
  initialState: {
    items: [],
    status: 'idle',
    error: null
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchPosts.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload;
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      });
  },
});

export default postsSlice.reducer;

Use thunk in component

// App.js
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchPosts } from './features/posts/postsSlice';

const App = () => {
  const dispatch = useDispatch();
  const posts = useSelector((state) => state.posts.items);
  const postStatus = useSelector((state) => state.posts.status);
  const error = useSelector((state) => state.posts.error);

  useEffect(() => {
    if (postStatus === 'idle') {
      dispatch(fetchPosts());
    }
  }, [postStatus, dispatch]);

  let content;

  if (postStatus === 'loading') {
    content = <p>Loading...</p>;
  } else if (postStatus === 'succeeded') {
    content = (
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    );
  } else if (postStatus === 'failed') {
    content = <p>{error}</p>;
  }

  return (
    <div>
      <h1>Posts</h1>
      {content}
    </div>
  );
};

export default App;

What is RTK Query?

  • RTK Query is a powerful data-fetching and caching tool built into Redux Toolkit (RTK). It simplifies data fetching and synchronization with the Redux store. RTK Query is designed to handle common requirements like caching, background updates, and re-fetching data.

Why Use RTK Query?

RTK Query is used to streamline the process of data fetching, caching, synchronization, and state management in Redux applications. It provides:

  • Automatic Caching: Automatically caches the fetched data, reducing the need to refetch data.
  • Background Fetching: Fetches data in the background and keeps the data synchronized with the server.
  • Auto-refetching: Automatically refetches data when certain conditions change, such as query parameters.
  • Optimistic Updates: Allows for optimistic updates, where the UI is updated before the server response is received.
  • Simplified API: Provides a simplified API for data fetching compared to traditional Redux patterns.
// src/services/api.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com/' }),
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => 'posts',
    }),
  }),
});

export const { useGetPostsQuery } = api;

REDUX VS REDUX TOOLKIT

Traditional Redux

  • Boilerplate Code: Requires more boilerplate code for actions, reducers, and store configuration.
  • Middleware Configuration: Requires manual setup of middleware like redux-thunk.
  • Immutability: Requires developers to manually ensure state immutability.
  • Data Fetching: Requires additional libraries or custom code for data fetching and caching.

Redux Toolkit (RTK)

  • Reduced Boilerplate: Uses createSlice, createAsyncThunk, and configureStore to reduce boilerplate code.
  • Built-in Middleware: Includes middleware like redux-thunk by default, with easier configuration.
  • Immutability with Immer: Uses Immer to allow "mutating" syntax for immutable state updates.
  • RTK Query: Provides built-in support for data fetching and caching, reducing the need for additional libraries.
⚠️ **GitHub.com Fallback** ⚠️