Открываешь проект двухлетней давности, чтобы поправить цвет одной кнопки. В итоге получаешь конфликт нативных зависимостей в CocoaPods, ошибку сборки Gradle под Android 12 и сломанную пагинацию в Redux.

Примерно так выглядело моё мобильное приложение в конце 2025 года: bare React Native, кастомные нативные модули и строгая 7-слойная архитектура Feature-Sliced Design (FSD). В начале 2026 года я завёл проект заново на Expo SDK 54, переписал навигацию на Expo Router и заменил Redux на Zustand + React Query. Кодовая база стала короче на 16 810 строк.

Что было не так

К концу 2025 года проблемы выглядели так:

  • Любая мелкая правка могла упереться в конфликт нативных зависимостей в CocoaPods или в ошибку Gradle на Android 12.
  • Redux Toolkit вместе с FSD превратился в генератор бойлерплейта: чтобы добавить одно поле, нужно было создать слайс, экшены, thunk, селекторы и зарегистрировать редьюсер в корне.
  • Навигация была «матрёшкой» из навигаторов в слое 2_processes. Чтобы понять, как пользователь попадает из чата в детали задачи, приходилось продираться через вложенные стеки.
  • Онбординг нового разработчика отнимал много времени: ручной pod install, настройка Gradle, подбор версий NDK и SDK, объяснение навигации и стейта. Когда я пришёл на проект, то постоянно спрашивал у коллег, какие у нас версии и как всё это запустить.

Почему с нуля

Апгрейд поверх старого проекта утонул бы в ручном разрешении конфликтов в project.pbxproj и build.gradle. Поэтому я пошёл по пути greenfield: новый проект через npx create-expo-app, перенос в него JS/TS-кода и вырезание легаси по дороге.

Цели были такие: вернуть контроль над нативной сборкой, убрать бойлерплейт Redux и разделить клиентское и серверное состояние, сделать архитектуру понятной любому React-разработчику и автоматизировать релизы.

Работа заняла пять месяцев и затронула 1545 файлов. В статистике ниже нет нативных папок ios/ и android/ (теперь их генерирует prebuild) и lock-файлов: это только код и конфиги.

+16 700
строк добавлено
−33 510
строк удалено
−16 810
чистый результат
1 545
файлов затронуто

Платформа и сборка

Раньше ios/ и android/ лежали в git, и любой SDK означал ручные правки в AppDelegate.mm и build.gradle. Версии компиляторов отличались от машины к машине.

Теперь нативные папки генерирует prebuild, и лежат они в .gitignore. Настройки (bundle ID, разрешения, иконки) живут в app.json, внешние SDK вроде Firebase подключают Config Plugins, а версии SDK и deployment target фиксирует expo-build-properties в app.config.js:

[
  'expo-build-properties',
  {
    android: {
      compileSdkVersion: 36,
      targetSdkVersion: 35,
      buildToolsVersion: '36.0.0'
    },
    ios: {
      useFrameworks: 'static',
      deploymentTarget: '15.1',
      buildReactNativeFromSource: true
    }
  }
]
Нативная сборка: app.json, expo prebuild --clean, папки ios и android, expo run, нативный билд app.json декларативная конфигурация npx expo prebuild --clean команда сборки автогенерация нативных папок /ios /android expo run:ios expo run:android нативный билд готовый бинарник сборка и запуск

bun run clean сносит ios/ и android/ и генерирует их заново, так что «призраков» старых сборок в кэше Xcode и Gradle больше нет. Новому разработчику теперь хватает клонирования, установки зависимостей и запуска: собирать окружение под конкретную машину не нужно, и вопросов «а как это у вас запускается» больше не возникает.

Навигация

Старая навигация держалась на пяти пакетах @react-navigation/* и занимала около 970 строк в слое 2_processes/navigation.

Основные удалённые файлы из 2_processes/navigation
Удалённые файлы навигации и число их строк general-tab-navigator −212 строк tasks-tab-navigator −179 строк authorized stack −95 строк detail-task-navigator −51 строка unauthorized stack −51 строка navigator + config −48 строк
Всего навигационного кода удалено ~970 строк, в списке самые крупные файлы. Вместо них 25 файлов файлового роутинга Expo Router.

Expo Router заменил это файловым роутингом: структура экранов повторяет структуру папок. Защищённая и гостевая зоны разведены по группам каталогов (auth) и (unauthorized), а модалки вынесены в прозрачный роут global-modal.tsx:

src/1_app/
├── index.tsx                         # Корневой редирект
├── _layout.tsx                       # Главный лейаут (провайдеры, инициализация)
├── global-modal.tsx                  # Прозрачный стек для Bottom Sheets
├── (unauthorized)/
│   ├── _layout.tsx                   # Стек гостевой зоны
│   ├── sign-in.tsx                   # Экран входа
│   └── sign-up.tsx                   # Экран регистрации (новая фича)
└── (auth)/
    ├── _layout.tsx                   # Стек авторизованной зоны
    ├── (main)/
    │   ├── _layout.tsx               # Главные табы (Задачи, Регламенты, Выход)
    │   ├── index.tsx                 # Главный экран
    │   ├── regulation/[id].tsx       # Детали регламента (динамический роут)
    │   └── settings/
    │       └── integrations.tsx      # Настройки интеграций
    └── (tasks)/
        └── board/
            ├── _layout.tsx           # Табы доски задач (4 вкладки)
            ├── my-tasks.tsx          # Мои задачи
            ├── controlled-tasks.tsx  # Контролируемые задачи
            ├── participated-tasks.tsx # Участвую
            └── today-tasks.tsx       # Задачи на сегодня

Auth Guard

Вместо рендера разных навигаторов по условию в корневом _layout.tsx работает реактивный хук useAuthGuard. Он следит за token и isGuest в Zustand и за состоянием видео-заставки, а при логауте переводит пользователя в гостевую зону. Хук занимает 72 строки, а раньше та же логика была размазана по нескольким навигаторам:

import { useUserStore } from '@/5_entities/user'
import {
  useGlobalSearchParams,
  usePathname,
  useRouter,
  useSegments
} from 'expo-router'
import { useEffect, useRef } from 'react'

export function useAuthGuard(
  isSessionLoading: boolean,
  isVideoFinished: boolean
) {
  const segments = useSegments()
  const pathname = usePathname()
  const router = useRouter()
  const { taskId } = useGlobalSearchParams<{ taskId?: string }>()

  const token = useUserStore(state => state.token)
  const isGuest = useUserStore(state => state.isGuest)
  const isAuthorized = !!token

  const wasAuthorizedRef = useRef(false)

  useEffect(() => {
    if (isSessionLoading || !isVideoFinished) return

    const inUnauthGroup = segments[0] === '(unauthorized)'
    const inMainSection = segments[1] === '(main)'

    if (isAuthorized) {
      wasAuthorizedRef.current = true

      if (inUnauthGroup) {
        router.replace({
          pathname: '/(auth)/(main)',
          params: taskId ? { taskId } : {}
        })
        return
      }

      if (pathname === '/') {
        router.replace({
          pathname: '/(auth)/(main)',
          params: taskId ? { taskId } : {}
        })
      }
    } else {
      if (!inUnauthGroup) {
        if (wasAuthorizedRef.current) {
          wasAuthorizedRef.current = false
          router.replace('/(unauthorized)/sign-in')
        } else {
          router.replace('/(unauthorized)/sign-up')
        }
      } else {
        if (inMainSection && !isGuest) {
          router.replace('/(unauthorized)/sign-in')
        }
      }
    }
  }, [
    isAuthorized,
    isGuest,
    isSessionLoading,
    isVideoFinished,
    segments,
    pathname,
    router,
    taskId
  ])
}

Вход и главный экран приложения. Все данные демонстрационные.

Холодный старт по deep link

Приложение закрыто, пользователь тапает по ссылке в Telegram: app://board/my-tasks?taskId=123. ОС запускает приложение, JS-бандл ещё инициализируется, сессия восстанавливается из хранилища, играет видео-сплэш. Если сразу перейти по ссылке, навигация сломается: стек не готов.

Холодный старт по deep link: ожидание сессии, проверка авторизации, открытие модалки задачи Запуск приложения Сессия восстановлена? НЕТ Показать сплэш-экран ДА Пользователь авторизован? ДА Зона (auth) Модалка по ссылке НЕТ Зона (unauthorized)

За ссылки отвечает хук useTaskDeepLink: он ждёт восстановления сессии и конца заставки и только потом открывает модалку задачи:

import { useFormModal } from '@/6_shared/providers/modal-provider'
import { useGlobalSearchParams, usePathname, useRouter } from 'expo-router'
import { useEffect, useRef } from 'react'

export function useTaskDeepLink(isAuthorized: boolean, isReady: boolean) {
  const { taskId } = useGlobalSearchParams<{ taskId?: string }>()
  const pathname = usePathname()
  const router = useRouter()
  const { openModal } = useFormModal()
  const lastProcessedTaskId = useRef<string | null>(null)

  useEffect(() => {
    if (!isAuthorized || !isReady) return

    if (!taskId) {
      lastProcessedTaskId.current = null
      return
    }

    if (taskId === lastProcessedTaskId.current) return

    if (pathname.includes('regulation')) {
      lastProcessedTaskId.current = taskId
      return
    }

    openModal('taskDetails', Number(taskId), {
      boardType: 'my',
      mode: 'edit'
    })

    lastProcessedTaskId.current = taskId

    // После открытия модалки сбрасываем taskId из параметров маршрута,
    // чтобы ссылка не сработала повторно
    setTimeout(() => {
      router.setParams({ taskId: '' })
    }, 0)
  }, [taskId, pathname, isAuthorized, isReady, openModal, router])
}

Стейт

Redux Toolkit с redux-persist держал и клиентское, и серверное состояние. Я заменил его на Zustand с Immer (локальный UI-стейт) и React Query (серверные данные), а AsyncStorage на react-native-mmkv.

Стейт: один Redux store с AsyncStorage против Zustand с MMKV для UI и React Query для серверных данных БЫЛО: ОДИН REDUX STORE СТАЛО: РАЗДЕЛЕНИЕ ОТВЕТСТВЕННОСТИ Компоненты экшены Redux store UI-стейт модалки, формы Серверные данные задачи, комментарии redux-persist AsyncStorage асинхронный JS-мост UI-СТЕЙТ Zustand + Immer локальные настройки persist MMKV синхронно, через JSI СЕРВЕРНЫЕ ДАННЫЕ WebSocket-события HTTP-запросы Кэш React Query серверное состояние WebSocket пишет прямо в кэш, без повторного HTTP

AsyncStorage → MMKV

redux-persist работал поверх AsyncStorage, а тот ходит через асинхронный JS-мост. Из-за этого на старте интерфейс мерцал: приложение не знало, авторизован ли пользователь, пока не разрешится промис чтения токена. MMKV написан на C++ и работает через JSI, поэтому читает данные синхронно.

AsyncStorage против MMKV: как читается токен
Чтение токена: у AsyncStorage путь идёт через асинхронный JS-мост и приводит к мерцанию интерфейса, у MMKV прямой синхронный вызов через JSI без мерцания ASYNCSTORAGE (СТАРЫЙ) Запрос токена JS Bridge Native Thread сериализация данных Promise Токен получен мерцание при старте MMKV (НОВЫЙ) Запрос токена JSI mmkv.getItem() прямой вызов C++ синхронно Токен получен без мерцания

Токен в API-клиенте теперь читается без await:

// src/6_shared/api/instance.ts
export const fetchMethod = async <T>(method: string, params: object = {}) => {
  // Синхронный вызов через JSI
  const token = mmkvStorage.getItem(USER_TOKEN)
  const response = await fetch(KANBOARD_URI, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`
    },
    body: JSON.stringify({ method, params })
  })
  // ...
}

WebSocket напрямую в кэш

Почти всё содержимое приложения — серверные данные (задачи, комментарии, чек-листы), поэтому оно лежит в кэше React Query. В Zustand остались сессия, UI-флаги и пара небольших справочников: счётчики и список регламентов. События по WebSocket не вызывают повторный HTTP-запрос, а пишутся прямо в кэш:

// src/1_app/providers/web-sockets-provider/web-sockets-provider.tsx
case 'comment.create': {
  const message = data.comment
  const taskId = Number(data.task_id || message.task_id)
  if (!taskId) return

  // Напрямую внедряем новое сообщение в кэш конкретного чата
  queryClient.setQueryData<MessagesResponse>(
    ['comments', { task_id: taskId }],
    oldData => {
      if (!oldData) return oldData
      // Защита от дублей по ID
      if (oldData.messages.some(m => m.id === message.id)) return oldData
      return { ...oldData, messages: [message, ...oldData.messages] }
    }
  )
  break
}

При логауте queryClient.clear() стирает кэш, и данные прошлого аккаунта не просочатся в следующий.

Архитектура FSD

Слой 2_processes отвечал только за навигационные стеки и авторизационные переключатели. Когда навигация переехала в Expo Router, а гард в корневой лейаут, делать ему стало нечего, и я его удалил. Слоёв стало шесть.

Сжатие иерархии FSD: 7 слоёв → 6
Слои FSD: было семь, стало шесть, слой processes удалён, роутинг переехал в Expo Router БЫЛО: 7 СЛОЁВ СТАЛО: 6 СЛОЁВ 1_app 2_processes 3_pages 4_widgets 5_features 6_entities 7_shared 1_app 2_pages 3_widgets 4_features 5_entities 6_shared слой удалён, роутинг в Expo Router
Слой 2_processes удалён: роутинг переехал в Expo Router.

Вторая проблема — циклические импорты между мелкими фичами и сущностями. Я собрал код вокруг крупных доменов (task, message, regulation). Каждый домен держит свои API-запросы с WebSocket-подписками, локальный стор и вспомогательные компоненты. Фичи и виджеты импортируют домены сверху вниз, а домены друг от друга не зависят, поэтому циклы ушли.

Фичи и виджеты импортируют домены entities сверху вниз, домены друг от друга не зависят features / widgets entities/task API + стейт + UI entities/message API + стейт + UI entities/regulation API + стейт + UI домены друг от друга не импортируют

Заодно я прошёлся по UI Kit: в 6_shared/ui было 64 компонента, среди них три разные кнопки и четыре текстовых поля. Сейчас их 22, а основу составляют пять универсальных: action-button, action-switch, dropdown, check-box и confirm-modal. Отдельно v-quill-content рисует JSON Quill Delta нативными компонентами и flexbox-сеткой вместо WebView, так что регламенты на 100+ страниц, по ощущениям, открываются быстрее.

Удалённые дубликаты UI-компонентов из 7_shared
UI Kit: 64 компонента, среди которых дубликаты, свелись к 22, основу которых составляют пять универсальных БЫЛО: 64 КОМПОНЕНТА СТАЛО: 22 КОМПОНЕНТА n-header n-hyperlink n-switch n-text-input v-bottom-sheet v-modal v-dropdown v-text-button v-remove-button v-image-button v-animated-flat-list select-input-button среди удалённых, список неполный action-button action-switch dropdown check-box confirm-modal основа: 5 универсальных
В shared/ui было 64 компонента, стало 22. Основу составляют 5 универсальных: action-button, action-switch, dropdown, check-box, confirm-modal.

Мёртвый код после переноса вычищал Knip: он строит граф импортов от точек входа и находит неиспользуемые файлы, типы и npm-пакеты (react-native-fast-image, redux-persist, @react-native-async-storage/async-storage и ещё около десятка).

Нативные модули

Легаси-зависимости я заменил аналогами из экосистемы Expo:

Нативные модули: было → стало
Замена нативных библиотек на аналоги из экосистемы Expo БЫЛО СТАЛО @dr.pogodin/react-native-fs react-native-blob-util react-native-file-viewer @react-native-documents/picker react-native-notifications react-native-device-info @react-native-clipboard/clipboard AsyncStorage redux-persist expo-file-system expo-sharing expo-document-picker expo-notifications expo-constants expo-application expo-clipboard react-native-mmkv expo-haptics

Баг на Android 12: BottomSheet блокирует касания

Проблема: лист BottomSheet от Gorhom в закрытом состоянии блокировал клики по фоновому интерфейсу. Причина: компонент оставался в дереве и перехватывал касания даже при index={-1}. Решение: я перешёл на BottomSheetModal: он монтируется только по вызову present() и при закрытии удаляется из дерева. Внутри модалки обычный ScrollView заменён на BottomSheetScrollView, иначе жесты прокрутки работают неправильно.

Карточка задачи в листе поверх доски: страницы с описанием, чатом и чек-листами.

// src/6_shared/providers/modal-provider/modal-provider.tsx
const MAX_STACK_DEPTH = 6

export const FormModalProvider = ({ children, sheetConfigs }: Props) => {
  const [stack, setStack] = useState<ModalStackItem[]>([])
  const stackRef = useRef<ModalStackItem[]>([])
  stackRef.current = stack

  // Слоты монтируются один раз и переиспользуются для каждого элемента стека
  const slotRefs = useRef(
    Array.from({ length: MAX_STACK_DEPTH }, () => React.createRef<BottomSheetModal>())
  )

  const openModal = useCallback(
    (sheet: SheetType, id: number | string, payload?: any) => {
      if (stackRef.current.length >= MAX_STACK_DEPTH) return

      const idx = stackRef.current.length
      setStack(prev => [
        ...prev,
        { key: `${sheet}-${id}-${Date.now()}`, sheet: sheetConfigs[sheet], id, payload }
      ])

      // present() в следующем тике: к этому моменту слот уже получил контент
      setTimeout(() => slotRefs.current[idx]?.current?.present(), 0)
    },
    [sheetConfigs]
  )

  const closeModal = useCallback(() => {
    slotRefs.current[stackRef.current.length - 1]?.current?.dismiss()
  }, [])

  return (
    <ModalContext.Provider value={{ openModal, closeModal }}>
      <BottomSheetModalProvider>
        {children}
        {slotRefs.current.map((ref, idx) => {
          const item = stack[idx]
          const Component = item?.sheet.component

          return (
            <BottomSheetModal
              key={`modal-stack-slot-${idx}`}
              ref={ref}
              stackBehavior="push"
              enablePanDownToClose
              backdropComponent={renderBackdrop}
              onChange={index => index === -1 && setStack(prev => prev.slice(0, idx))}
            >
              {item && Component ? (
                <Component {...item.payload} id={String(item.id)} onClose={closeModal} />
              ) : null}
            </BottomSheetModal>
          )
        })}
      </BottomSheetModalProvider>
    </ModalContext.Provider>
  )
}

Стек появился позже: задача открывается поверх задачи, например по ссылке из чата. Поэтому провайдер держит шесть слотов BottomSheetModal, которые монтируются один раз при старте. Если создавать новый компонент на каждый openModal, у него ещё нет измеренного layout, и первый present() занимает заметное время, особенно на Android. Порядок стека определяется порядком вызовов present(), а не порядком монтирования слотов.

Заодно я добавил свайп вниз для закрытия просмотра изображений вместо крестика в углу экрана.

Задачи и регламенты

В приложении два больших раздела: задачи и регламенты. Регламент — это документ, который сотрудник изучает, а затем сдаёт по нему тест.

Доска задач: список задач, скелетоны при загрузке и переключение колонок.

Раньше у каждой колонки доски был свой почти одинаковый код: 14 отдельных фич и один большой Redux-слайс.

Теперь вкладка — это один компонент, а колонки задаются конфигом. Первый запрос загружает первую страницу всех колонок сразу, а дальше каждая колонка догружается отдельно, когда её долистали до конца.

export const useGetColumnTasks = (type, columnId, initialTasks: Task[]) =>
  useInfiniteQuery<Task[]>({
    queryKey: ['boardTasks', 'column', type, columnId],
    // первая страница уже пришла в общем запросе по доске
    initialData: { pages: [initialTasks], pageParams: [0] },
    staleTime: Infinity,
    initialPageParam: 0,
    queryFn: async ({ pageParam }) => {
      const response = await fetchMethod<getTasksByBoardResponse>('getTasksByBoard', {
        board: type,
        column_id: columnId,
        offset: pageParam as number
      })
      const columnData = Array.isArray(response)
        ? response.find(c => c.column_id === columnId)
        : null
      return columnData?.tasks || []
    },
    // смещение = сколько задач уже загружено в эту колонку
    getNextPageParam: (lastPage, allPages) =>
      lastPage.length === 0
        ? undefined
        : allPages.reduce((acc, page) => acc + page.length, 0)
  })

Если потянуть список вниз, обновляются все колонки сразу:

const onRefreshAll = async () => {
  const { data: freshData } = await refetchInitial()
  if (!freshData) return

  columnsConfig.forEach(({ column_id }) => {
    const tasks = freshData.find(d => d.column_id === column_id)?.tasks || []
    queryClient.setQueryData<InfiniteData<Task[]>>(
      ['boardTasks', 'column', type, column_id],
      { pages: [tasks], pageParams: [0] }
    )
  })
}

Числа в заголовках колонок и значки на вкладках лежат в Zustand-сторе counts. Данные приходят из запроса getCounts, который выполняется при запуске приложения, а сам стор сохраняется в MMKV, поэтому цифры видны сразу при открытии доски. Пока задачи грузятся, показываются скелетоны.

Регламент и тест по нему: вопросы, результат и повторная попытка.

Раньше вопросы теста хранились сразу в нескольких местах, а вся логика сидела в одной большой фиче. Состояние теста было общим на всё приложение и пропадало, если выйти по кнопке «назад».

Теперь экран теста получает вопросы готовыми, а прогресс по каждому регламенту хранится отдельно: на каком вопросе остановился и какие ответы выбраны. Если выйти и вернуться, тест продолжится с того же места, пока приложение не закрыто. Ошибки считаются один раз, в конце: для каждого вопроса проверяется, совпали ли выбранные ответы с правильными.

const errors = questions.filter((q, i) => {
  const correct = q.answers.filter(a => a.is_correct).map(a => a.id)
  const selected = answers.get(i) || []
  return (
    correct.length !== selected.length ||
    !correct.every(id => selected.includes(id))
  )
}).length

Без ошибок регламент считается изученным, с ошибками тест можно пройти заново.

Релизы

Версию и changelog считает semantic-release: он был в проекте ещё до миграции (.releaserc и скрипт release:semantic). Новое здесь другое: нативные папки перед каждой сборкой генерирует expo prebuild, а собирает и выкладывает в сторы Fastlane.

У каждой платформы по два lane. Android: beta выкладывает сборку в Internal Testing в Google Play, release в Production. iOS: beta идёт в TestFlight, release в App Store, а отдельный lane renew_cert обновляет просроченные сертификаты Match. Запускаются они через npm-скрипты (beta:ios, release:android и так далее): сначала expo prebuild, потом Fastlane.

Путь от коммита до стора
Путь от коммита до стора: semantic-release, sync-version, expo prebuild, fastlane beta или release semantic-release sync-version.js expo prebuild fastlane beta TestFlight + Internal Testing release App Store + Google Play

Цена и риски

Миграция с нуля не бесплатна:

  • Ограничения экосистемы. Часть нативных модулей могла не перенестись из-за того, что Expo их не поддерживает. Экосистема сейчас стабильная, и сложных нативных модулей, которым не нашлось бы замены, по ходу работы не обнаружилось. На проекте с кастомным нативным кодом такой подход пришлось бы оценивать отдельно.
  • Продукт не стоял на месте. Перед переписыванием я довёл старую версию до ума и вычистил из неё баги, чтобы у пользователей было меньше поводов отвлекать меня от миграции. Всё это время старая версия жила в master, а новая в отдельной ветке expo-migration. Старый функционал я продолжал поддерживать в master всё время миграции, так что приходилось держать в голове две кодовые базы.

Итог

Итоги: до и после
Итоги миграции: что было и что стало по архитектуре, стейту, навигации, нативной части, хранилищу и релизам БЫЛО СТАЛО Архитектура FSD 7 слоёв 6 слоёв Стейт Redux + RTK Zustand + React Query Навигация React Navigation Expo Router Нативная часть Вручную prebuild Хранилище AsyncStorage MMKV Сборка и релизы Вручную Fastlane

Цифры: удалено более 33 000 строк, добавлено 16 700, итого минус 16 810.

  • Онбординг. Новичку не нужно возиться с нативным окружением и разбираться в самописной навигации: Expo, Zustand и React Query знает любой React-разработчик.
  • Стейт. Клиентское и серверное состояния разделены: Zustand хранит лёгкие локальные настройки, а кэшем, пагинацией и инвалидацией сетевых данных занимается React Query.
  • Релизы. Сборка и выкладка в сторы идут одной командой через Fastlane, а версия и changelog считаются автоматически.

В проект больше не страшно заходить. Работы над функциональностью впереди ещё много, но фундамент под неё чистый.