State Management
State management is crucial for building maintainable React Native applications. This guide covers local state patterns, Context API usage, and integration with popular state management libraries.
Local State Management
useState Patterns
import { useState, useCallback } from "react"
// Basic state management
function UserProfile() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const updateProfile = useCallback(async (userData: Partial<User>) => {
try {
setLoading(true)
setError(null)
const updatedUser = await updateUserAPI(userData)
setUser(updatedUser)
} catch (err) {
setError(err instanceof Error ? err.message : "Update failed")
} finally {
setLoading(false)
}
}, [])
return (
<View>
{loading && <ActivityIndicator />}
{error && <Text style={styles.error}>{error}</Text>}
{user && <UserDetails user={user} onUpdate={updateProfile} />}
</View>
)
}
useReducer for Complex State
import { useReducer } from "react"
// Define state shape
interface FormState {
values: Record<string, any>
errors: Record<string, string>
touched: Record<string, boolean>
isSubmitting: boolean
}
// Define actions
type FormAction =
| { type: "SET_FIELD"; field: string; value: any }
| { type: "SET_ERROR"; field: string; error: string }
| { type: "SET_TOUCHED"; field: string }
| { type: "SET_SUBMITTING"; isSubmitting: boolean }
| { type: "RESET_FORM" }
// Reducer function
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "SET_FIELD":
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: "" },
}
case "SET_ERROR":
return {
...state,
errors: { ...state.errors, [action.field]: action.error },
}
case "SET_TOUCHED":
return {
...state,
touched: { ...state.touched, [action.field]: true },
}
case "SET_SUBMITTING":
return { ...state, isSubmitting: action.isSubmitting }
case "RESET_FORM":
return {
values: {},
errors: {},
touched: {},
isSubmitting: false,
}
default:
return state
}
}
// Custom hook for form management
function useForm(initialValues: Record<string, any> = {}) {
const [state, dispatch] = useReducer(formReducer, {
values: initialValues,
errors: {},
touched: {},
isSubmitting: false,
})
const setField = useCallback((field: string, value: any) => {
dispatch({ type: "SET_FIELD", field, value })
}, [])
const setError = useCallback((field: string, error: string) => {
dispatch({ type: "SET_ERROR", field, error })
}, [])
const setTouched = useCallback((field: string) => {
dispatch({ type: "SET_TOUCHED", field })
}, [])
const reset = useCallback(() => {
dispatch({ type: "RESET_FORM" })
}, [])
return {
...state,
setField,
setError,
setTouched,
reset,
dispatch,
}
}
Context API Patterns
Authentication Context
// contexts/AuthContext.tsx
import React, { createContext, useContext, useReducer, useEffect } from "react"
import AsyncStorage from "@react-native-async-storage/async-storage"
interface User {
id: string
email: string
name: string
}
interface AuthState {
user: User | null
token: string | null
loading: boolean
error: string | null
}
type AuthAction =
| { type: "AUTH_LOADING" }
| { type: "AUTH_SUCCESS"; user: User; token: string }
| { type: "AUTH_ERROR"; error: string }
| { type: "AUTH_LOGOUT" }
| { type: "CLEAR_ERROR" }
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
switch (action.type) {
case "AUTH_LOADING":
return { ...state, loading: true, error: null }
case "AUTH_SUCCESS":
return {
...state,
user: action.user,
token: action.token,
loading: false,
error: null,
}
case "AUTH_ERROR":
return {
...state,
user: null,
token: null,
loading: false,
error: action.error,
}
case "AUTH_LOGOUT":
return {
...state,
user: null,
token: null,
loading: false,
error: null,
}
case "CLEAR_ERROR":
return { ...state, error: null }
default:
return state
}
}
interface AuthContextType {
state: AuthState
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
register: (email: string, password: string, name: string) => Promise<void>
clearError: () => void
}
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(authReducer, {
user: null,
token: null,
loading: true,
error: null,
})
// Initialize auth state from storage
useEffect(() => {
initializeAuth()
}, [])
const initializeAuth = async () => {
try {
const token = await AsyncStorage.getItem("auth_token")
if (token) {
// Validate token and get user info
const user = await validateToken(token)
dispatch({ type: "AUTH_SUCCESS", user, token })
} else {
dispatch({ type: "AUTH_LOGOUT" })
}
} catch (error) {
dispatch({ type: "AUTH_ERROR", error: "Failed to initialize auth" })
}
}
const login = async (email: string, password: string) => {
try {
dispatch({ type: "AUTH_LOADING" })
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Login failed")
}
await AsyncStorage.setItem("auth_token", data.token)
dispatch({ type: "AUTH_SUCCESS", user: data.user, token: data.token })
} catch (error) {
dispatch({
type: "AUTH_ERROR",
error: error instanceof Error ? error.message : "Login failed",
})
}
}
const logout = async () => {
try {
await AsyncStorage.removeItem("auth_token")
dispatch({ type: "AUTH_LOGOUT" })
} catch (error) {
console.error("Logout error:", error)
}
}
const register = async (email: string, password: string, name: string) => {
try {
dispatch({ type: "AUTH_LOADING" })
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Registration failed")
}
await AsyncStorage.setItem("auth_token", data.token)
dispatch({ type: "AUTH_SUCCESS", user: data.user, token: data.token })
} catch (error) {
dispatch({
type: "AUTH_ERROR",
error: error instanceof Error ? error.message : "Registration failed",
})
}
}
const clearError = () => {
dispatch({ type: "CLEAR_ERROR" })
}
return (
<AuthContext.Provider
value={{
state,
login,
logout,
register,
clearError,
}}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error("useAuth must be used within an AuthProvider")
}
return context
}
App Settings Context
// contexts/SettingsContext.tsx
import React, { createContext, useContext, useState, useEffect } from "react"
import AsyncStorage from "@react-native-async-storage/async-storage"
interface AppSettings {
theme: "light" | "dark" | "system"
language: string
notifications: {
push: boolean
email: boolean
inApp: boolean
}
privacy: {
analytics: boolean
crashReporting: boolean
}
}
const defaultSettings: AppSettings = {
theme: "system",
language: "en",
notifications: {
push: true,
email: true,
inApp: true,
},
privacy: {
analytics: true,
crashReporting: true,
},
}
interface SettingsContextType {
settings: AppSettings
updateSettings: (updates: Partial<AppSettings>) => Promise<void>
resetSettings: () => Promise<void>
loading: boolean
}
const SettingsContext = createContext<SettingsContextType | null>(null)
export function SettingsProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = useState<AppSettings>(defaultSettings)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadSettings()
}, [])
const loadSettings = async () => {
try {
const storedSettings = await AsyncStorage.getItem("app_settings")
if (storedSettings) {
setSettings({ ...defaultSettings, ...JSON.parse(storedSettings) })
}
} catch (error) {
console.error("Failed to load settings:", error)
} finally {
setLoading(false)
}
}
const updateSettings = async (updates: Partial<AppSettings>) => {
try {
const newSettings = { ...settings, ...updates }
setSettings(newSettings)
await AsyncStorage.setItem("app_settings", JSON.stringify(newSettings))
} catch (error) {
console.error("Failed to save settings:", error)
}
}
const resetSettings = async () => {
try {
setSettings(defaultSettings)
await AsyncStorage.removeItem("app_settings")
} catch (error) {
console.error("Failed to reset settings:", error)
}
}
return (
<SettingsContext.Provider
value={{
settings,
updateSettings,
resetSettings,
loading,
}}
>
{children}
</SettingsContext.Provider>
)
}
export function useSettings() {
const context = useContext(SettingsContext)
if (!context) {
throw new Error("useSettings must be used within a SettingsProvider")
}
return context
}
External State Management
Zustand Integration
// store/authStore.ts
import { create } from "zustand"
import { persist, createJSONStorage } from "zustand/middleware"
import AsyncStorage from "@react-native-async-storage/async-storage"
interface User {
id: string
email: string
name: string
}
interface AuthStore {
user: User | null
token: string | null
loading: boolean
// Actions
setUser: (user: User, token: string) => void
logout: () => void
setLoading: (loading: boolean) => void
}
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
user: null,
token: null,
loading: false,
setUser: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
setLoading: (loading) => set({ loading }),
}),
{
name: "auth-store",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
user: state.user,
token: state.token,
}),
}
)
)
// Usage in components
function LoginScreen() {
const { setUser, setLoading, loading } = useAuthStore()
const handleLogin = async (email: string, password: string) => {
setLoading(true)
try {
const response = await loginAPI(email, password)
setUser(response.user, response.token)
} finally {
setLoading(false)
}
}
return (
<View>
{loading && <ActivityIndicator />}
{/* Login form */}
</View>
)
}
Redux Toolkit Integration
// store/store.ts
import { configureStore } from "@reduxjs/toolkit"
import { persistStore, persistReducer } from "redux-persist"
import AsyncStorage from "@react-native-async-storage/async-storage"
import authSlice from "./slices/authSlice"
import settingsSlice from "./slices/settingsSlice"
const persistConfig = {
key: "root",
storage: AsyncStorage,
whitelist: ["auth", "settings"],
}
const persistedAuthReducer = persistReducer(persistConfig, authSlice)
export const store = configureStore({
reducer: {
auth: persistedAuthReducer,
settings: settingsSlice,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ["persist/PERSIST", "persist/REHYDRATE"],
},
}),
})
export const persistor = persistStore(store)
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
// store/slices/authSlice.ts
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"
interface User {
id: string
email: string
name: string
}
interface AuthState {
user: User | null
token: string | null
loading: boolean
error: string | null
}
const initialState: AuthState = {
user: null,
token: null,
loading: false,
error: null,
}
export const loginAsync = createAsyncThunk(
"auth/login",
async ({ email, password }: { email: string; password: string }) => {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
throw new Error("Login failed")
}
return response.json()
}
)
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
logout: (state) => {
state.user = null
state.token = null
state.error = null
},
clearError: (state) => {
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(loginAsync.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(loginAsync.fulfilled, (state, action) => {
state.loading = false
state.user = action.payload.user
state.token = action.payload.token
})
.addCase(loginAsync.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || "Login failed"
})
},
})
export const { logout, clearError } = authSlice.actions
export default authSlice.reducer
Data Fetching and Caching
React Query Integration
// hooks/useAPI.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
interface User {
id: string
name: string
email: string
}
// Fetch user data
export function useUser(userId: string) {
return useQuery({
queryKey: ["user", userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
throw new Error("Failed to fetch user")
}
return response.json()
},
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
})
}
// Update user mutation
export function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ userId, data }: { userId: string; data: Partial<User> }) => {
const response = await fetch(`/api/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error("Failed to update user")
}
return response.json()
},
onSuccess: (updatedUser) => {
// Update cache
queryClient.setQueryData(["user", updatedUser.id], updatedUser)
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: ["users"] })
},
})
}
// Usage in component
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useUser(userId)
const updateUserMutation = useUpdateUser()
const handleUpdate = (data: Partial<User>) => {
updateUserMutation.mutate({ userId, data })
}
if (loading) return <ActivityIndicator />
if (error) return <Text>Error: {error.message}</Text>
return (
<View>
<Text>{user.name}</Text>
<Text>{user.email}</Text>
<Button
title="Update"
onPress={() => handleUpdate({ name: "New Name" })}
disabled={updateUserMutation.isPending}
/>
</View>
)
}
State Management Best Practices
Component State Guidelines
// ✅ Keep state close to where it's used
function Counter() {
const [count, setCount] = useState(0)
return (
<View>
<Text>{count}</Text>
<Button onPress={() => setCount((c) => c + 1)} title="+" />
</View>
)
}
// ✅ Lift state up when shared between components
function TodoApp() {
const [todos, setTodos] = useState<Todo[]>([])
return (
<View>
<TodoList todos={todos} onToggle={toggleTodo} />
<AddTodo onAdd={addTodo} />
</View>
)
}
// ❌ Avoid storing derived state
function UserProfile({ user }: { user: User }) {
// ❌ Don't do this
const [displayName, setDisplayName] = useState(user.name.toUpperCase())
// ✅ Do this instead
const displayName = user.name.toUpperCase()
}
Performance Optimization
import { memo, useMemo, useCallback } from "react"
// Memoize expensive calculations
function ExpensiveComponent({ data }: { data: any[] }) {
const processedData = useMemo(() => {
return data.map((item) => expensiveProcessing(item))
}, [data])
return <List data={processedData} />
}
// Memoize callbacks to prevent unnecessary re-renders
function TodoList({ todos, onToggle }: TodoListProps) {
const handleToggle = useCallback(
(id: string) => {
onToggle(id)
},
[onToggle]
)
return (
<FlatList
data={todos}
renderItem={({ item }) => <TodoItem todo={item} onToggle={handleToggle} />}
keyExtractor={(item) => item.id}
/>
)
}
// Memoize components to prevent unnecessary re-renders
const TodoItem = memo(({ todo, onToggle }: TodoItemProps) => {
return (
<Pressable onPress={() => onToggle(todo.id)}>
<Text style={todo.completed && styles.completed}>{todo.text}</Text>
</Pressable>
)
})
Testing State Management
Testing Context Providers
// __tests__/AuthContext.test.tsx
import { render, act, waitFor } from "@testing-library/react-native"
import { AuthProvider, useAuth } from "@/contexts/AuthContext"
function TestComponent() {
const { state, login } = useAuth()
return (
<View>
<Text testID="user-status">{state.user ? state.user.name : "Not logged in"}</Text>
<Button testID="login-button" onPress={() => login("test@example.com", "password")} title="Login" />
</View>
)
}
describe("AuthContext", () => {
const renderWithAuth = (component: React.ReactElement) => {
return render(<AuthProvider>{component}</AuthProvider>)
}
it("should login user successfully", async () => {
const { getByTestId } = renderWithAuth(<TestComponent />)
expect(getByTestId("user-status")).toHaveTextContent("Not logged in")
await act(async () => {
fireEvent.press(getByTestId("login-button"))
})
await waitFor(() => {
expect(getByTestId("user-status")).toHaveTextContent("Test User")
})
})
})
Next Steps
Continue with these related topics:
- Data & Networking - API integration and data fetching patterns
- Performance Optimization - Optimizing state updates and renders
- Testing Strategies - Testing state management logic