О том как использовать стейт-менеджер для хранения состояния в приложении и как хранить данные между его перезапусками.
На самом деле также как на вебе за исключением одного нюанса — в React Native используется AsyncStorage в качестве хранилища постоянных данных (между перезапуском).
Сегодняшние гости:
- Immer: иммутабельность без боли
- AsyncStorage: аналог LocalStorage на вебе
- Redux Toolkit: все часто используемые пакеты redux в одном месте
- TypeScript: типизация для удобства поиска и отладки
Redux Toolkit уже использует immer, поэтому отдельно его ставить не придётся. Всё, что необходимо установить одной командой:
$ yarn add @react-native-async-storage/async-storage \
@reduxjs/toolkit react-redux redux redux-persist
Структура приложения, если решите поэкспериментировать:
.
├── android
├── ios
├── src
│ ├── screens
│ │ └── PostsScreen.tsx
│ ├── services
│ │ └── api
│ │ ├── fetch.ts
│ │ └── posts.ts
│ └── store
│ ├── blog.ts
│ └── index.ts
├── App.tsx
└── index.js
API
React Native поддерживает такие библиотеки как axios
, но в этой заметке
попробуем обойтись стандартным fetch
, доступным всегда. Чтобы было удобнее
им пользоваться, создадим для него небольшую обёртку с реализацией
типичных методов.
// services/api/fetch.ts
async function http<T>(path: string, config: RequestInit): Promise<T> {
const request = new Request(path, config)
const response = await fetch(request)
if (!response.ok) {
// для примера просто текст, при желании доработать и возвращать ответ сервера
throw new Error(response.statusText)
}
// во избежание ошибки вернуть пустой объект если нет тела
return response.json().catch(() => ({}))
}
// get-запрос
export async function get<T>(path: string, config?: RequestInit): Promise<T> {
const init = { method: 'get', ...config }
return await http<T>(path, init)
}
// post-запрос
export async function post<T, U>(path: string, body: T, config?: RequestInit): Promise<U> {
const init = { method: 'post', body: JSON.stringify(body), ...config }
return await http<U>(path, init)
}
// и put-запрос
export async function put<T, U>(path: string, body: T, config?: RequestInit): Promise<U> {
const init = { method: 'put', body: JSON.stringify(body), ...config }
return await http<U>(path, init)
}
Теперь напишем один из запросов с использванием этой обёртки. Не вдаваясь глубоко в детали, без учёта типов ошибок для TS:
// services/api/posts.ts
import * as fetch from './fetch'
// структура JSON-ответа: что ожидаем получить
export type ResponseBodyPostById = {
id: number
title: string
body: string
userId: number
}
// передавать id, получать ответ типа ResponseBodyPostById
export const getPostById = async (id: number) => {
return await fetch
.get<ResponseBodyPostById>(`https://jsonplaceholder.typicode.com/posts/${id}`)
}
Storage
Одна из возможных частей хранилища: записи блога. Функция createSlice
помогает
не допускать бойлерплейта, который обязательно появится в коде при использовании
«голого» redux.
// store/blog.ts
import { createAsyncThunk, createSlice, isRejected } from '@reduxjs/toolkit'
import { getPostById } from '../services/api/posts'
// какие поля содержит тело поста
interface IPost {
id: number
title: string
body: string
userId: number
}
// структура хранилища для блога: список постов
interface IState {
posts: IPost[]
loading: Boolean
}
// тип для redux-thunk
export interface IThunkConfig {
state: IState
}
// инициализация хранилища по-умолчанию
const initialState: IState = {
posts: [],
loading: false,
}
// асинхронный метод thunk
// дёргает getPostById и получает ответ
export const fetchPostById = createAsyncThunk(
'blog/post',
async (id: number) => getPostById(id)
)
// создать часть хранилища
const blogSlice = createSlice({
name: 'blog', // название
initialState, // начальное состояние
reducers: {}, // обычные методы
// асинхронные методы
extraReducers: builder => {
builder
// активировать статус loading при отправке запроса
.addCase(fetchPostById.pending, state => {
state.loading = true
})
// что делать при успешном разрешении промиса
.addCase(fetchPostById.fulfilled, (state, action) => {
state.posts.push(action.payload)
state.loading = false
})
// что делать при ошибке
.addMatcher(isRejected, state => {
state.loading = false
})
// что делать по-умолчанию
.addDefaultCase(state => state)
},
})
export default blogSlice
Осталось настроить связку Redux + AsyncStorage.
Примечание
Для приложения на React Native следует использовать AsyncStorage.
Для веба импортировать и использовать storage
:
import storage from 'redux-persist/lib/storage'
// store/index.ts
import AsyncStorage from '@react-native-async-storage/async-storage'
import { configureStore } from '@reduxjs/toolkit'
import { combineReducers } from 'redux'
import {
persistReducer,
persistStore,
FLUSH,
PAUSE,
PERSIST,
PURGE,
REGISTER,
REHYDRATE,
} from 'redux-persist'
import blogSlice from './blog'
// объединение всех частей хранилища
const rootReducer = combineReducers({ blog: blogSlice.reducer })
// конфигурация для redux-persist
const persistConfig = {
key: 'App',
version: 1,
storage: AsyncStorage, // только для React Native
whitelist: ['blog'], // белый список: какую часть store хранить
// blacklist: ['blog'],
}
const persistedReducer = persistReducer(persistConfig, rootReducer)
export const store = configureStore({
reducer: persistedReducer,
middleware: getDefaultMiddleware => [
...getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, PAUSE, PERSIST, PURGE, REGISTER, REHYDRATE],
},
}),
],
devTools: process.env.NODE_ENV !== 'production',
})
export const persistor = persistStore(store)
Для типизации (и удобного автокомплита) redux-хуков придётся
либо каждый раз указывать тип, либо единожды прописать типы
в кастомных функциях useAppDispatch/useAppSelector
и пользоваться ими.
Выглядит так себе, но есть лайфхак: декларация модуля react-redux
. Стейт по-умолчанию
должен наследовать наш собственный рутовый стейт.
// store/index.ts
export const persistor = persistStore(store)
// в конец добавить всего пару строк: тип стейта
export type RootState = ReturnType<typeof store.getState>
// и декларацию модуля
declare module 'react-redux' {
interface DefaultRootState extends RootState {}
}
В этом случае всё тоже работает как ожидается и без кастомных хуков.
Screen
Всё готово. Получим ответ от API и выведем первый пост. Для упрощения селекторы пишу прямо в теле скрина, но в реальности лучше выносить их отдельно.
// screens/PostsScreen.tsx
import React from 'react'
import { Text, TouchableOpacity, View } from 'react-native'
import { useDispatch, useSelector } from 'react-redux'
import { fetchPostById } from '../store/blog'
const PostsScreen = () => {
const dispatch = useDispatch()
// получить из хранилища необходимые данные
const posts = useSelector(state => state.blog.posts)
const isLoading = useSelector(state => state.blog.loading)
// дёрнуть fetchPostById
const loadPosts = () => {
dispatch(fetchPostById(1))
}
// пока промис не разрешился выводить прелоадер
if (isLoading) {
return <ActivityIndicator size="large" />
}
return (
<View>
<TouchableOpacity onPress={loadPosts}>
<Text>Take a post</Text>
</TouchableOpacity>
{posts.map(({ title, id }) => (
<Text style={{ fontWeight: 'bold' }} key={id}>
{title}
</Text>
))}
</View>
)
}
export default PostsScreen
А в точке входа приложения (что это будет за точка и где зависит от навигации, если она используется)
подключить сконфигурированные ранее store
(хранение в приложении) и persistor
(хранение между сессиями).
// App.tsx
import React from 'react'
import { SafeAreaView, ScrollView, StatusBar } from 'react-native'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import { store, persistor } from './src/store'
import PostsScreen from './src/screens/PostsScreen'
const App = () => (
<SafeAreaView>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<Provider store={store}>
<PersistGate persistor={persistor}>
<PostsScreen />
</PersistGate>
</Provider>
</ScrollView>
</SafeAreaView>
)
export default App
Теперь части хранилища, добавленные в whitelist
, будут обрабатываться redux-persist
и оставаться в приложении так долго как это будет нужно. Остальное как обычно в redux.