Using Redux Toolkit in React Native: Getting started and usage guide

Using Redux Toolkit in React Native: Getting started and usage guide

Redux Toolkit is an official package from the Redux Team that helps configuring Redux store and reduces boilerplate. It also includes many convenience features that help supercharge your state management. Using Redux Toolkit in React Native is straightforward, so let's setup a simple application and see it in action! You can follow along the article or if you prefer you can also find all of the example code on our GitHub repo.


Project setup

To get started, I'm going to create a new React Native project:

$ npx react-native init ReduxToolkitExample

Next, let's install all the necessary dependencies:

$ npm install react-redux @reduxjs/toolkit

Basic state setup

Create state slice

Let's start by creating a simple state slice that holds message state. Redux Toolkit provides a convenience function createSlice that helps setting up and managing a state slice. It requires providing a name for the slice, initial state values and reducer functions that manage state updates.

import { createSlice } from "@reduxjs/toolkit"

const messageSlice = createSlice({
  name: "message",
  initialState: {
    message: "Initial message"
  },
  reducers: {}
})

export default messageSlice.reducer

Normally in Redux we would have to setup a slice reducer function with action types and action creators, but createSlice simplifies this by auto-generating action types and action creators based on the names of reducer functions. In addition, thanks to the included Immer library we can write immutable state updates with mutable code. With that in mind, let's create and export our reducer function that changes message state.

import { createSlice, PayloadAction } from "@reduxjs/toolkit"

const messageSlice = createSlice({
  name: "message",
  initialState: {
    message: "Initial message"
  },
  reducers: {
    setMessage(state, action: PayloadAction<string>) {
      state.message = action.payload
    }
  }
})

export const { setMessage } = messageSlice.actions
export default messageSlice.reducer

Export store

Now that we have our state slice, we need to create a store and add our message slice reducer function. Redux Toolkit provides a configureStore helper function that has good default configuration options and automatically combines reducers. Additionally, you'll want to export AppDispatch and RootState types that infer from the store itself, updating them as we make changes to the store. Here's how everything should look:

import { configureStore } from '@reduxjs/toolkit';
import messageReducer from './message';

export const store = configureStore({
  reducer: {
    message: messageReducer
  }
});

export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;

It's also recommended to create a separate file that exports typed dispatch and selector hooks, which will save you from importing AppDispatch and RootState every time you want to use them:

import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import { RootState, AppDispatch } from './store';

export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Lastly, wrap your app in Redux Provider component and pass down that store export we created earlier:

import { Provider } from 'react-redux';
import { store } from './store';
...
const App = () => {
  return (
    <Provider store={store}>
      ...
    </Provider>
  );
};
...

Setup Message component

Finally, let's create a simple component that will select our message, display it and perform a state update after we click a button.

import { useAppDispatch, useAppSelector } from '../hooks';
...

const Message = () => {
  const dispatch = useAppDispatch();
  const { message } = useAppSelector(state => state.message);

  const handlePress = () => {
    dispatch(setMessage('Message from Component'));
  };

  return (
    <View style={styles.container}>
      <Text style={styles.text}>{message}</Text>
      <Button title={'Set Message'} onPress={handlePress} />
    </View>
  );
};
...

Here's how it looks so far:

0:00
/0:04

Using Thunk Actions

Create users state slice

That was fun! But let's step it up a notch and take a look at asynchronous operations. I would like to fetch and store user data from https://reqres.in/ API, so let's setup a new users state slice that will contain an array of users. I'm also going to create a UserData interface that models to the data returned by the API.

import { createSlice } from "@reduxjs/toolkit"

interface UserData {
  id: number
  email: string
  first_name: string
  last_name: string
  avatar: string
}

const usersSlice = createSlice({
  name: "users",
  initialState: {
    users: [] as UserData[],
    loading: false
  },
  reducers: {}
})

export default usersSlice.reducer

Setup Async Thunk

Async logic in Redux generally relies on redux-thunk middleware and Redux Toolkit's configureStore automatically sets that up for us. To use it, let's import createAsyncThunk and create our user data fetching thunk function in users state slice that returns parsed data.

import { createAsyncThunk } from '@reduxjs/toolkit';
...
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
  const response = await fetch('https://reqres.in/api/users?delay=1');
  return (await response.json()).data as UserData[];
});
...

createAsyncThunk will then generate three action creators: pending, fulfilled, and rejected. Each of these action creators will be attached to our thunk action and we can reference their action types in reducer and respond to them when they are dispatched.

Redux Toolkit also provides extraReducers function that can respond to other action types beside the ones generated by createSlice. Let's use that here to setup our reducers that will update our users state:

const usersSlice = createSlice({
  name: "users",
  initialState: {
    users: [] as UserData[],
    loading: false
  },
  reducers: {},
  extraReducers: builder => {
    builder.addCase(fetchUsers.pending, state => {
      state.loading = true
    })
    builder.addCase(fetchUsers.fulfilled, (state, action) => {
      state.users = action.payload
      state.loading = false
    })
    builder.addCase(fetchUsers.rejected, state => {
      state.loading = false
    })
  }
})

Export users reducer

Now we only need to add our new users state slice reducer to the store and the setup is complete.

...
import usersReducer from './users';

export const store = configureStore({
  reducer: {
    message: messageReducer,
    users: usersReducer
  }
});
...

Setup Users component

Lastly, let's create a new component that will fetch and display our user data when it first renders. I'm also going to add a reload button so the data could be re-fetched whenever.

...
import { fetchUsers } from '../store/users';

const Users = () => {
  const dispatch = useAppDispatch();
  const { users, loading } = useAppSelector(state => state.users);

  useEffect(() => {
    dispatch(fetchUsers());
  }, []);

  if (loading) {
    return <ActivityIndicator size="large" style={styles.loader} />;
  }

  return (
    <View>
      <Button title={'Reload'} onPress={() => dispatch(fetchUsers())} />
      {users.map((user) => {
        return (
          <View style={styles.container} key={user.id}>
            <View>
              <View style={styles.dataContainer}>
                <Text>
                  {user.first_name} {user.last_name}
                </Text>
              </View>
              <View style={styles.dataContainer}>
                <Text>{user.email}</Text>
              </View>
            </View>
          </View>
        );
      })}
    </View>
  );
};
...

And this is how our User component looks in action:

0:00
/0:06

Full code example for this part can be found here.


Advanced setup: Entity Adapter

Setting up initial state

That already looks really good, but there is one more improvement we can do. Redux Toolkit also provides createEntityAdapter function that generates a set of prebuilt reducers and selectors for performing CRUD operations on a normalized state structure. Our user data is one of such structures, so we can make use of it.

Let's add entity adapter to our users state. First create usersAdapter that infers UserData type definitions, then replace initialState with adapter's getInitialState function that will return a new entity state in the following form: { ids: [], entities: {} }

To keep our loading state we can pass it to getInitialState, which accepts an optional object as an argument that will be merged into the returned initial state value.

import { createEntityAdapter } from '@reduxjs/toolkit';
...

export const usersAdapter = createEntityAdapter<UserData>();

const usersSlice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState({
    loading: false
  }),
...

Replacing state update

Entity adapter provides many CRUD functions that help with adding, updating, and removing entity instances from an entity state object. Let's update our reducer function that references fulfilled action type to use setAll when updating our user data.

...
builder.addCase(fetchUsers.fulfilled, (state, action) => {
  usersAdapter.setAll(state, action.payload);
  state.loading = false;
});
...

Adding prebuilt selectors

Now that we have our entity adapter set up, we can make use of prebuilt selectors to help with selecting the content we want, which in our case is selectAll to select an array of all users. Add and export this selector in our users state file.

import { RootState } from '.';
...
const usersSelectors = usersAdapter.getSelectors(
  (state: RootState) => state.users,
);

export const selectAllUsers = usersSelectors.selectAll;
...

Updating Users component

Finally, we can make use of this new selector in our User component. Let's select all of our users so we can keep displaying them.

...
import { fetchUsers, selectAllUsers } from '../store/users';

const Users = () => {
  const dispatch = useAppDispatch();
  const { loading } = useAppSelector(state => state.users);
  const users = useAppSelector(selectAllUsers);
...

If you need to see full example code, you can find it on our repository.


Data fetching and caching with RTK Query

Creating API slice and adding queries

RTK Query is an additional optional tool that can simplify common data fetching patterns and caching logic. Built on top of other Redux Toolkit's APIs it helps with request's state tracking, optimistic UI updates, and cache lifetime management. Let's try making use of it in our application.

Similarly as before, we'll create a new state slice, however this time we'll use RTK Query's createApi to make it an "API slice" that will handle data fetching. For querying we can use a new endpoint from https://reqres.in/ that returns data about colors. Define ColorData interface and colorApi service that includes baseQuery and endpoints parameters to fetch all our data. Then, export auto-generated hooks that we'll use to fetch the data in our component. Here's how it looks:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

interface ColorData {
  id: number;
  name: string;
  year: number;
  color: string;
  pantone_value: string;
}

export const colorsApi = createApi({
  reducerPath: 'colorsApi',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://reqres.in/api/' }),
  endpoints: builder => ({
    getColors: builder.query<{ data: ColorData[] }, void>({
      query: () => 'colors?per_page=12',
    }),
    getColorById: builder.query<{ data: ColorData }, number>({
      query: id => `colors/${id}?delay=1`,
    }),
  }),
});

export const { useGetColorsQuery, useGetColorByIdQuery } = colorsApi;

After that, we need to configure our store by adding a slice reducer that was auto-generated by our new API slice. Additionally, we need to include a custom middleware that enables caching and manages subscription lifetimes.

import { colorsApi } from './colors';
...
export const store = configureStore({
  reducer: {
	...
    [colorsApi.reducerPath]: colorsApi.reducer,
  },
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware().concat(colorsApi.middleware),
});
...

And with that we're ready to call our hooks and fetch the data. I've setup a new Colors component that uses one of the auto-generated hooks to fetch all colors as soon as it's rendered and displays appropriate UI elements throughout the lifecycle of the request, such as loading spinner or error message. Upon successfully fetching the data it will display all colors in a list with a name and a preview of the color. I also added an additional component that will render based on expanded state that is set by pressing a toggle next to each color name. Once expanded, it will fetch detailed information about the selected color using the other hook that we exported previously. Take a look below:

...
import { useGetColorsQuery } from '../../store/colors';
import ColorDetails from '../components/ColorDetails';

const Colors = () => {
  const [selectedColorId, setSelectedColorId] = useState<number | null>(null);
  const { data: colors, error, isFetching } = useGetColorsQuery();

  if (isFetching) {
    return <ActivityIndicator size="large" style={styles.loader} />;
  }

  if (error) {
    return <Text>Error while loading data</Text>;
  }

  if (!colors) {
    return <Text>No data</Text>;
  }

  return (
    <View>
      {colors.data.map(({ id, name, color }) => {
        const isSelected = id === selectedColorId;

        return (
          <View style={styles.container} key={id}>
            <View style={styles.colorHeaderContainer}>
              <View style={styles.colorLabel}>
                <View
                  style={[styles.colorCircle, { backgroundColor: color }]}
                />
                <Text style={styles.capitalizedText}>{name}</Text>
              </View>
              <Button
                title={isSelected ? 'Collapse' : 'Expand'}
                onPress={() => setSelectedColorId(isSelected ? null : id)}
              />
            </View>
            {isSelected && (
              <View style={styles.colorDetailsContainer}>
                <ColorDetails id={selectedColorId} />
              </View>
            )}
          </View>
        );
      })}
    </View>
  );
};
...

And here is the ColorDetails component that renders based on expanded toggle mentioned before. Upon the first render of this component RTK Query will fetch color details from the API and start a subscription to this data in the cache. Any subsequent render of this component that subscribes to the same data will fetch the cached data instead. Furthermore, additional components that subscribe to the same endpoint and parameter combination will use cached data and won't make another identical request.

...
import { useGetColorByIdQuery } from '../../store/colors';

const ColorDetails = (props: { id: number }) => {
  const { data: color, isFetching } = useGetColorByIdQuery(props.id);

  if (isFetching) {
    return <ActivityIndicator size="large" style={styles.loader} />;
  }

  if (!color) {
    return <Text>No data</Text>;
  }

  const { name, id, year, color: hex, pantone_value } = color.data;

  const renderDetails = (label: string, value: string | number) => (
    <View style={styles.detailsContainer}>
      <Text style={styles.label}>{label}: </Text>
      <Text style={styles.capitalizedText}>{value}</Text>
    </View>
  );

  return (
    <>
      {renderDetails('ID', id)}
      {renderDetails('Year', year)}
      {renderDetails('Name', name)}
      {renderDetails('Hex Code', hex)}
      {renderDetails('Pantone Value', pantone_value)}
    </>
  );
};
...

Full example code is available on our repository.

Cache behaviour

By default, data is kept in the cache until 60 seconds after the last active subscription for a particular endpoint and parameter combination ends. That means if we toggle the same color detail component within 60 seconds it will use cached data and won't refetch. We can see this behaviour more clearly if we reduce the subscription time with keepUnusedDataFor in getColorById endpoint.

export const colorsApi = createApi({
  ...
  endpoints: builder => ({
    ...
    getColorById: builder.query<{ data: ColorData }, number>({
      query: id => `colors/${id}?delay=1`,
      keepUnusedDataFor: 5,
    }),
  }),
});

Following video demonstrates adjusted subscription time behaviour. Notice how after first colors' details are fetched and afterwards collapsed for more than 5 seconds it will unsubscribe from the cache and re-fetch when details are expanded again towards the end of the video. However, toggling fetched colors within the unused data window will not perform the re-fetch.

0:00
/0:21

There are many other options that allow manipulating cache behaviour and tailoring them to your needs. Making use of RTK Query's caching is a great way to improve your app's performance without hand-writing such logic.


Conclusion

Redux Toolkit provides many advantages when building state management solution, especially when combined with asynchronous requests and normalized data. Using it in React Native is very easy and should definitely be considered over regular Redux workflow. Hope you found this informative and if you would like to learn more about Redux Toolkit, I encourage you to check out the official documentation.

If you want to know more about using Redux Toolkit in React Native read this two-part blogpost:
Using Redux in Flutter Part 1 - Redux setup and basic state management: flutter redux example
Using Redux in Flutter Part 2 - Thunk actions: flutter redux middleware


This blogpost has been reviewed and updated as of 17.01.2024! The following changes have been made:

  • Updated example project dependencies
  • Updated video previews
  • Added RTK Query section
  • Various smaller text changes