Состояние приложения в React Native

О том как использовать стейт-менеджер для хранения состояния в приложении и как хранить данные между его перезапусками.

На самом деле также как на вебе за исключением одного нюанса — в 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.