Redux Toolkit (RTK) is the official, recommended way to write Redux. It removes most of the boilerplate that gave Redux its reputation: no hand-written action types, no switch statements, no spread-everything immutable updates. This guide sets it up in a React Native app with TypeScript, builds a small todo feature with a synchronous slice and an async API call, connects it to components with typed hooks, and tests it.
I built everything below in a fresh React Native 0.87 project with Redux Toolkit 2.13 and React Redux 9.3. It passed the TypeScript check, ESLint and four Jest tests, and bundled for Android. This guide also replaces this site’s older tutorial on plain Redux with React Native, so it starts with the core ideas and ends with how to migrate classic Redux code.
The 2023 version of this post used APIs that no longer exist, most importantly the standalone getDefaultMiddleware import, which Redux Toolkit 2.0 removed, and it added redux-thunk by hand even though Redux Toolkit already includes it. This rewrite uses the current APIs.
Redux in Two Minutes
Redux keeps shared app state in one place and changes it in a predictable way. Five ideas cover almost everything:
| Concept | What it is | In Redux Toolkit |
|---|---|---|
| Store | The single object that holds your app’s shared state | configureStore |
| Action | A plain object describing what happened, such as { type: "todos/todoAdded", payload: "Buy milk" } | Generated by createSlice |
| Reducer | A pure function that takes the current state and an action and returns the next state | The reducers in a slice |
| Dispatch | How you send an action to the store | useAppDispatch() |
| Selector | A function that reads a piece of state | useAppSelector(selectTodos) |
Data flows one way: a component dispatches an action, the reducers compute the new state, and components that selected that state re-render. That predictability is the main reason to use Redux. It is not required for every app; for a few values that rarely change, such as a theme or the signed-in user, React Context is often enough. For a web-focused overview, see managing global state with React Redux.
The Redux maintainers are clear that Redux Toolkit is how to write Redux today: if you write any Redux logic, they recommend writing it with Redux Toolkit, and they consider the plain redux core package obsolete for new code. That is why this guide does not teach hand-written reducers and action types first.
Redux vs Redux Toolkit
| Aspect | Hand-written Redux | Redux Toolkit |
|---|---|---|
| Store setup | createStore plus manual middleware and DevTools wiring | configureStore with good defaults (thunk, dev-mode checks, DevTools support) |
| Actions | Action type constants and action creators written by hand | Generated by createSlice |
| Reducers | Switch statements and immutable spreads | “Mutating” code made safe by Immer |
| Async logic | Hand-written thunks and loading flags | createAsyncThunk, or RTK Query for API caching |
| TypeScript | Lots of manual typing | Types inferred from the store and slices |
Plain createStore still works but is marked deprecated in Redux to steer people toward Redux Toolkit.
Step 1: Create a Project and Install Redux Toolkit
Create a React Native app if you do not have one. The React Native docs recommend Expo for new apps; the community CLI also works. Do not install the old global react-native-cli, which the docs say to remove.
npx create-expo-app@latest MyApp
# or, without a framework:
npx @react-native-community/cli@latest init MyApp
cd MyApp
npm install @reduxjs/toolkit react-redux@reduxjs/toolkit contains Redux itself, Immer, Reselect and the thunk middleware. react-redux provides the Provider component and hooks. There is no longer anything to “link”: native linking is automatic, and these packages are plain JavaScript anyway.
A folder structure that scales well is organizing by feature:
src/
store/
index.ts # configureStore and types
hooks.ts # typed useAppDispatch / useAppSelector
features/
todos/
todosSlice.ts
TodoList.tsx
__tests__/Step 2: Create the Store
configureStore combines your slice reducers, adds the thunk middleware and development-only checks for accidental mutations and non-serializable values, and enables Redux DevTools support. Exporting a makeStore function as well as the store makes testing easier, because each test can get a fresh store.
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
export const makeStore = () =>
configureStore({
reducer: {
todos: todosReducer,
},
});
export const store = makeStore();
export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore['getState']>;
export type AppDispatch = AppStore['dispatch'];You do not need combineReducers: passing an object to reducer combines them for you.
Step 3: Add Typed Hooks
Instead of typing useSelector and useDispatch in every component, create pre-typed versions once. React Redux 9 provides .withTypes() for this:
import { useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from './index';
// Typed versions of the React Redux hooks. Use these everywhere instead of
// the plain useDispatch and useSelector.
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();Step 4: Write a Slice
A slice holds the state, reducers and generated action creators for one feature. This one manages todos, with a prepare callback that generates IDs, plus an async thunk that loads sample todos from an API and tracks loading and error states:
import { createAsyncThunk, createSlice, nanoid, type PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '../../store';
export type Todo = { id: string; title: string; done: boolean };
type TodosState = {
items: Todo[];
status: 'idle' | 'loading' | 'failed';
error: string | null;
};
const initialState: TodosState = { items: [], status: 'idle', error: null };
// Loads starter todos from an API. createAsyncThunk dispatches pending,
// fulfilled and rejected actions for us.
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=3');
if (!res.ok) throw new Error(`Request failed with ${res.status}`);
const data: { id: number; title: string; completed: boolean }[] = await res.json();
return data.map(t => ({ id: String(t.id), title: t.title, done: t.completed }));
});
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// "Mutating" code is safe here: Immer turns it into an immutable update.
todoAdded: {
reducer(state, action: PayloadAction<Todo>) {
state.items.push(action.payload);
},
prepare(title: string) {
return { payload: { id: nanoid(), title, done: false } };
},
},
todoToggled(state, action: PayloadAction<string>) {
const todo = state.items.find(t => t.id === action.payload);
if (todo) todo.done = !todo.done;
},
todoRemoved(state, action: PayloadAction<string>) {
state.items = state.items.filter(t => t.id !== action.payload);
},
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, state => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'idle';
state.items.push(...action.payload);
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message ?? 'Something went wrong';
});
},
});
export const { todoAdded, todoToggled, todoRemoved } = todosSlice.actions;
export default todosSlice.reducer;
export const selectTodos = (state: RootState) => state.todos.items;
export const selectRemainingCount = (state: RootState) =>
state.todos.items.filter(t => !t.done).length;Points worth noticing:
- “Mutating” reducers are safe.
state.items.push(...)looks like a mutation, but Immer records it and produces a new immutable state. Only do this insidecreateSliceandcreateReducer. - Action creators are generated.
todoAdded,todoToggledandtodoRemovedcome fromtodosSlice.actions. createAsyncThunkhandles the lifecycle. It dispatchespending,fulfilledandrejectedactions, which the slice handles inextraReducers. A thrown error ends up inaction.error.message.- Selectors live with the slice. Components ask for
selectRemainingCountrather than knowing the state shape.
Step 5: Provide the Store and Use It in Components
Wrap the app in Provider:
import { StyleSheet } from 'react-native';
import { Provider } from 'react-redux';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { store } from './src/store';
import { TodoList } from './src/features/todos/TodoList';
export default function App() {
return (
<Provider store={store}>
<SafeAreaProvider>
<SafeAreaView style={styles.root}>
<TodoList />
</SafeAreaView>
</SafeAreaProvider>
</Provider>
);
}
const styles = StyleSheet.create({ root: { flex: 1 } });Then read state with useAppSelector and dispatch actions with useAppDispatch:
import { useState } from 'react';
import { Button, FlatList, Pressable, Text, TextInput, View, StyleSheet } from 'react-native';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import {
fetchTodos,
selectRemainingCount,
selectTodos,
todoAdded,
todoToggled,
} from './todosSlice';
export function TodoList() {
const dispatch = useAppDispatch();
const todos = useAppSelector(selectTodos);
const remaining = useAppSelector(selectRemainingCount);
const status = useAppSelector(state => state.todos.status);
const [title, setTitle] = useState('');
const add = () => {
if (!title.trim()) return;
dispatch(todoAdded(title.trim()));
setTitle('');
};
return (
<View style={styles.container}>
<Text style={styles.heading}>{remaining} left to do</Text>
<View style={styles.row}>
<TextInput
style={styles.input}
placeholder="New todo"
value={title}
onChangeText={setTitle}
onSubmitEditing={add}
/>
<Button title="Add" onPress={add} />
</View>
<Button
title={status === 'loading' ? 'Loading…' : 'Load sample todos'}
onPress={() => dispatch(fetchTodos())}
disabled={status === 'loading'}
/>
<FlatList
data={todos}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<Pressable onPress={() => dispatch(todoToggled(item.id))} accessibilityRole="checkbox"
accessibilityState={{ checked: item.done }}>
<Text style={item.done ? styles.done : undefined}>{item.title}</Text>
</Pressable>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 12 },
heading: { fontSize: 20, fontWeight: '600' },
row: { flexDirection: 'row', gap: 8 },
input: { flex: 1, borderWidth: 1, borderColor: '#ccc', borderRadius: 6, paddingHorizontal: 8 },
done: { textDecorationLine: 'line-through', color: '#888' },
});Only select the data a component needs. useAppSelector re-renders the component when the selected value changes, so selecting the whole state object causes unnecessary renders.
Step 6: Test It
Because reducers are pure functions and the store is created by makeStore, tests are straightforward. Slice tests check reducers and the async thunk with a mocked fetch:
import reducer, { todoAdded, todoToggled, todoRemoved, fetchTodos } from '../todosSlice';
import { makeStore } from '../../../store';
describe('todos slice', () => {
it('adds, toggles and removes a todo', () => {
let state = reducer(undefined, todoAdded('Write MRD'));
expect(state.items).toHaveLength(1);
const id = state.items[0].id;
state = reducer(state, todoToggled(id));
expect(state.items[0].done).toBe(true);
state = reducer(state, todoRemoved(id));
expect(state.items).toHaveLength(0);
});
it('loads todos with the async thunk', async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => [{ id: 1, title: 'From API', completed: false }],
}) as unknown as typeof fetch;
const store = makeStore();
await store.dispatch(fetchTodos());
expect(store.getState().todos.items[0].title).toBe('From API');
expect(store.getState().todos.status).toBe('idle');
});
it('records an error when the request fails', async () => {
globalThis.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch;
const store = makeStore();
await store.dispatch(fetchTodos());
expect(store.getState().todos.status).toBe('failed');
expect(store.getState().todos.error).toBe('Request failed with 500');
});
});A component test renders the real store with React Native Testing Library and interacts like a user. In version 14 of the library, render and user events are async, so await them:
import { render, screen, userEvent } from '@testing-library/react-native';
import { Provider } from 'react-redux';
import { makeStore } from '../../../store';
import { TodoList } from '../TodoList';
test('adding and completing a todo updates the count', async () => {
const user = userEvent.setup();
await render(
<Provider store={makeStore()}>
<TodoList />
</Provider>,
);
await user.type(screen.getByPlaceholderText('New todo'), 'Buy milk');
await user.press(screen.getByRole('button', { name: 'Add' }));
expect(screen.getByText('1 left to do')).toBeOnTheScreen();
await user.press(screen.getByRole('checkbox', { name: 'Buy milk' }));
expect(screen.getByText('0 left to do')).toBeOnTheScreen();
});One setup detail tripped me up: React Redux and Redux Toolkit ship ES modules, and React Native’s default Jest preset does not transform packages in node_modules, so the first run failed with “Cannot use import statement outside a module”. Adding the packages to transformIgnorePatterns fixed it:
module.exports = {
preset: '@react-native/jest-preset',
// react-redux and Redux Toolkit ship ES modules; let Babel transform them.
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?|react-redux|@reduxjs/toolkit|immer|redux|reselect)/)',
],
};$ npx jest
PASS src/features/todos/__tests__/todosSlice.test.ts
PASS src/features/todos/__tests__/TodoList.test.tsx
Tests: 4 passed, 4 totalFetching Server Data: Consider RTK Query
createAsyncThunk is fine for occasional requests. If much of your state is data from an API, Redux Toolkit’s built-in RTK Query handles fetching, caching, loading flags, refetching and cache invalidation for you, so you write an API definition instead of thunks and reducers. In React Native, set refetchOnFocus and refetchOnReconnect only after wiring setupListeners to the app’s focus and network events.
Debugging
The old advice to open the Redux DevTools browser extension does not apply to a React Native app, which does not run in your browser. Current options:
- React Native DevTools, built into React Native, for console logs, breakpoints, the React component tree with props and state, profiling and, since 0.83, network requests.
- Reactotron, a desktop debugging app with a Redux plugin (
reactotron-redux) for inspecting state and actions. - In Expo apps,
redux-devtools-expo-dev-plugin, which brings the full Redux DevTools to Expo’s dev tools plugin system.
I have not set up the last two for this post, so follow their own documentation.
Migrating Classic Redux Code
If your app was written with createStore, hand-written action types and connect, you can migrate one piece at a time; old and new code work side by side in the same store.
- Replace
createStorewithconfigureStore, passing your existing root reducer. Thunks and DevTools keep working, and you can delete manual middleware setup. - Convert one reducer at a time to
createSlice, keeping the same state shape. Old action creators can be replaced by the generated ones. - Replace hand-written thunks that track loading flags with
createAsyncThunkor RTK Query. - Replace
connectwith hooks. React Redux 9.3 marks connect as deprecated: it still works and will not be removed, but the hooks API is recommended.
A component written with connect:
// Before: classic connect
const TodoCount = ({ remaining, addTodo }) => (
<Button title={`Add (${remaining} left)`} onPress={() => addTodo('New todo')} />
);
const mapStateToProps = state => ({
remaining: state.todos.items.filter(t => !t.done).length,
});
const mapDispatchToProps = { addTodo: title => ({ type: 'ADD_TODO', title }) };
export default connect(mapStateToProps, mapDispatchToProps)(TodoCount);becomes, with the typed hooks and slice from this guide:
// After: hooks + Redux Toolkit
export function TodoCount() {
const dispatch = useAppDispatch();
const remaining = useAppSelector(selectRemainingCount);
return (
<Button title={`Add (${remaining} left)`} onPress={() => dispatch(todoAdded('New todo'))} />
);
}Common Mistakes
| Mistake | Fix |
|---|---|
Importing getDefaultMiddleware from the package | Use the callback: middleware: getDefault => getDefault().concat(myMiddleware) |
Adding redux-thunk manually | It is already included by configureStore |
| Mutating state outside a slice reducer | Only “mutate” inside createSlice/createReducer |
| Putting non-serializable values (class instances, functions) in state | Keep state as plain data; the dev-mode check will warn you |
Turning off serializableCheck to silence warnings | Fix the data instead, or ignore specific paths or actions only |
| Using Redux for every piece of state | Keep form input and UI-only state in components with useState |
Conclusion
With Redux Toolkit, a React Native store is a few small files: configureStore for the store, typed hooks, one slice per feature, and createAsyncThunk or RTK Query for server data. The code is shorter, safer to change and easy to test. If you are weighing React Native itself, see React vs React Native.
FAQs
Should I learn plain Redux before Redux Toolkit?
Learn the concepts (store, actions, reducers, dispatch and selectors), but write the code with Redux Toolkit. The Redux maintainers recommend Redux Toolkit for all Redux logic and consider hand-written Redux obsolete for new code.
Is connect deprecated in React Redux?
Yes. React Redux 9.3 marks connect as deprecated in favor of the hooks API. It still works and the maintainers do not plan to remove it, so existing components can be migrated gradually.
Is Redux Toolkit the same as Redux?
Redux Toolkit is the official package for writing Redux. It includes Redux and adds helpers such as configureStore, createSlice and createAsyncThunk that remove most of the boilerplate.
Do I need redux-thunk with Redux Toolkit?
No. configureStore includes the thunk middleware by default, and createAsyncThunk builds on it.
Why is getDefaultMiddleware undefined?
Redux Toolkit 2.0 removed the standalone getDefaultMiddleware export. Use the callback form instead: middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(yourMiddleware).
Should I use Redux Toolkit or React Context?
Context works well for values that change rarely, such as a theme or the signed-in user. Redux Toolkit suits larger shared state that changes often, needs predictable updates, debugging tools or caching of server data.
Can I persist the Redux store in React Native?
Yes. A common approach is redux-persist with AsyncStorage as the storage engine. Persist only what you need, and be careful with sensitive data.
How do I debug Redux in React Native?
Use React Native DevTools for components, logs and breakpoints, and a tool such as Reactotron with its Redux plugin, or the Redux DevTools Expo plugin in Expo apps, to inspect actions and state.








