React Native state bugs often appear between actions. A loading indicator never disappears, an older response overwrites newer results, or a screen displays data from the previous session.
Testing only the final value in a store can miss these problems. Reliable tests need to verify state transitions, the interface that consumes them, and the order in which asynchronous work completes.
This guide explains how to test React Native stores using Jest, Zustand, and React Native Testing Library, with additional guidance for Redux applications.
What Should React Native State Tests Cover?
A useful testing strategy separates three responsibilities:
| Test layer | What it verifies | Example |
|---|---|---|
| Store tests | State transitions and business rules | A failed request sets an error and ends loading |
| Component integration tests | Store subscriptions and visible behavior | Pressing “Load profile” displays the returned name |
| Device-level tests | Behavior involving the native application | A persisted session survives an app restart |
React Native’s documentation recommends separating business logic from components to make it independently testable. It also notes that JavaScript component tests cannot validate all underlying iOS and Android behavior. Critical native flows still need device or simulator coverage.
For asynchronous state, test these outcomes deliberately: pending, success, failure, retry, and competing requests.
Build a Store That Can Be Tested Independently
A store factory creates a fresh instance for every test. Injecting the API dependency lets tests control request completion without changing the store’s actual behavior.
The following TypeScript example uses Zustand’s vanilla store API:
// profileStore.ts
import { createStore } from 'zustand/vanilla';
export type Profile = { name: string };
export type ProfileApi = {
getProfile: () => Promise<Profile>;
};
type ProfileState = {
profile: Profile | null;
status: 'idle' | 'loading' | 'success' | 'error';
error: string | null;
load: () => Promise<void>;
};
export function createProfileStore(api: ProfileApi) {
return createStore<ProfileState>()((set) => ({
profile: null,
status: 'idle',
error: null,
load: async () => {
set({ status: 'loading', error: null, profile: null });
try {
const profile = await api.getProfile();
set({ profile, status: 'success' });
} catch {
set({
status: 'error',
error: 'Unable to load profile',
});
}
},
}));
}
export type ProfileStore =
ReturnType<typeof createProfileStore>;This example intentionally clears previous data when loading starts. An application that retains data during refresh should implement and test that different contract.
It also assumes one active request at a time. Concurrent requests require an additional policy, discussed below.
Test Loading and Success With a Controlled Promise
Immediately resolved mocks are convenient, but they can make intermediate loading behavior difficult to observe in component tests.
A deferred promise lets the test decide when a request finishes:
// testUtils.ts
export function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}The store test can now inspect both sides of the asynchronous boundary:
import { createProfileStore, Profile } from './profileStore';
import { deferred } from './testUtils';
test('transitions from idle to loading to success', async () => {
const request = deferred<Profile>();
const store = createProfileStore({
getProfile: () => request.promise,
});
expect(store.getState().status).toBe('idle');
const pending = store.getState().load();
expect(store.getState().status).toBe('loading');
expect(store.getState().profile).toBeNull();
request.resolve({ name: 'Asha' });
await pending;
expect(store.getState()).toMatchObject({
status: 'success',
profile: { name: 'Asha' },
error: null,
});
});No arbitrary delay is needed. The test controls the event that changes the state.
Because this test does not render React components, it does not need React’s act() wrapper.
Test Failure and Recovery Together
A failure test should verify more than the presence of an error. It should also check whether another attempt can recover.
test('clears the error when a retry succeeds', async () => {
const getProfile = jest.fn()
.mockRejectedValueOnce(new Error('Network unavailable'))
.mockResolvedValueOnce({ name: 'Asha' });
const store = createProfileStore({ getProfile });
await store.getState().load();
expect(store.getState()).toMatchObject({
status: 'error',
error: 'Unable to load profile',
profile: null,
});
const retry = store.getState().load();
expect(store.getState().status).toBe('loading');
expect(store.getState().error).toBeNull();
await retry;
expect(store.getState()).toMatchObject({
status: 'success',
profile: { name: 'Asha' },
error: null,
});
});This catches a common defect: a successful retry that leaves an old error visible.
Here, load() handles the rejection internally. Its promise resolves after error state is recorded, so a .rejects assertion would test the wrong contract.
Verify That Store Changes Reach the Screen
Store tests cannot detect broken subscriptions or incorrect rendering conditions. Add an integration test using the real store.
// ProfileScreen.tsx
import React from 'react';
import { Button, Text, View } from 'react-native';
import { useStore } from 'zustand';
import { ProfileStore } from './profileStore';
export function ProfileScreen({
store,
}: {
store: ProfileStore;
}) {
const status = useStore(store, (state) => state.status);
const profile = useStore(store, (state) => state.profile);
const error = useStore(store, (state) => state.error);
const load = useStore(store, (state) => state.load);
return (
<View>
<Button
title="Load profile"
disabled={status === 'loading'}
onPress={() => void load()}
/>
{status === 'loading' && <Text>Loading profile</Text>}
{error && <Text accessibilityRole="alert">{error}</Text>}
{profile && <Text>{profile.name}</Text>}
</View>
);
}The following test uses the asynchronous render API documented in React Native Testing Library 14.x. Projects on older versions should follow their installed version’s setup and API documentation.
import React from 'react';
import {
act,
render,
screen,
userEvent,
} from '@testing-library/react-native';
import { ProfileScreen } from './ProfileScreen';
import { createProfileStore, Profile } from './profileStore';
import { deferred } from './testUtils';
test('shows loading and then the returned profile', async () => {
const request = deferred<Profile>();
const store = createProfileStore({
getProfile: () => request.promise,
});
const user = userEvent.setup();
await render(<ProfileScreen store={store} />);
await user.press(
screen.getByRole('button', { name: 'Load profile' }),
);
expect(screen.getByText('Loading profile')).toBeOnTheScreen();
await act(async () => {
request.resolve({ name: 'Asha' });
await request.promise;
});
expect(await screen.findByText('Asha')).toBeOnTheScreen();
expect(screen.queryByText('Loading profile')).toBeNull();
});userEvent simulates interaction sequences rather than invoking only a single handler. Its asynchronous interactions should be awaited. React Native Testing Library recommends it for supported interactions such as pressing and typing.
The explicit act() here handles the externally triggered promise resolution while a subscribed component is mounted.
Choose the Right Async Assertion
React Native Testing Library provides different tools for different expectations:
| API | Appropriate use |
|---|---|
getBy* | An element should exist immediately |
queryBy* | Checking that an element is absent |
findBy* | Waiting for an element to appear |
waitFor | Waiting for an assertion, such as a mock call, to pass |
waitForElementToBeRemoved | Waiting for an initially present element to disappear |
waitFor retries its callback until it stops throwing or reaches its timeout. Returning false does not make it keep waiting.
// Incorrect: returning false still counts as completion.
await waitFor(() => store.getState().status === 'success');
// Correct: the assertion throws until the condition is met.
await waitFor(() => {
expect(store.getState().status).toBe('success');
});Keep actions outside waitFor. A button press inside its callback can run repeatedly and trigger duplicate requests.
Prefer awaiting a known operation directly in store tests. Use polling assertions when observing behavior whose completion is exposed through the interface or another external effect.
Test Out-of-Order Responses Explicitly
Search, filtering, and refresh operations can overlap.
Suppose request A starts first, request B starts second, and B finishes first. If A later overwrites B, the screen displays stale data.
A deterministic race test should:
- Create two deferred requests.
- Start A, then B.
- Resolve B and assert its result.
- Resolve A and assert that B remains authoritative.
For a “latest request wins” policy, one implementation option is a monotonically increasing request identifier:
let latestRequestId = 0;
// Inside the async action:
const requestId = ++latestRequestId;
const result = await api.search(query);
if (requestId !== latestRequestId) return;
set({ results: result, status: 'success' });Apply the same guard to the error path. Otherwise, an older failed request can replace a newer success with an error.
Cancellation, deduplication, and merging results are different policies. Tests should encode the behavior the product actually requires.
Use Fake Timers for Time-Based Behavior
Debouncing and scheduled retries should not make tests wait in real time. Jest’s fake timers allow tests to advance scheduled work deliberately.
For a 300-millisecond debounce, assert that the callback has not executed after 299 milliseconds, then advance one more millisecond and assert execution.
When timer callbacks update mounted React components, advance timers inside act():
await act(async () => {
await jest.advanceTimersByTimeAsync(300);
});Enable fake timers before scheduling the work and restore real timers during cleanup. Cancel or finish application timers intentionally; blindly running every timer can trigger recurring work.
Advancing time does not resolve an API promise controlled by a deferred mock. Timer completion and request completion are separate events.
Keep Stores and External State Isolated
Create a new store per test wherever possible. For singleton Zustand stores, the official testing guide demonstrates resetting state with getInitialState() and setState(initialState, true).
Resetting memory alone is insufficient for persisted state. Tests may also need to clear mocked storage and await hydration. Outstanding requests, subscriptions, and timers must be finished or cancelled to prevent updates leaking into later tests.
For Redux applications, render components inside a real Provider with a fresh store. Redux recommends integration tests and mocking requests at the network boundary rather than mocking selectors or dispatch hooks.
The injected API used above keeps the examples focused. It does not test HTTP parsing, status handling, or request construction. Cover that adapter separately or use network interception in broader integration tests.
Build Confidence Around Observable Behavior
A strong React Native state suite verifies the transition into loading, successful results, failures, recovery, and request ordering. Component tests then confirm that those transitions produce the intended interface.
Controlled promises make asynchronous work predictable. Fresh stores prevent cross-test interference. Targeted device tests cover behavior that JavaScript tests cannot establish.
Together, these practices help catch state bugs where they occur: between an interaction, an asynchronous operation, and the next screen update.





















Add Comment