Открываешь проект двухлетней давности, чтобы поправить цвет одной кнопки. В итоге получаешь конфликт нативных зависимостей в 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-файлов: это только код и конфиги.
Платформа и сборка
Раньше 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
}
}
]
bun run clean сносит ios/ и android/ и генерирует их заново, так что «призраков» старых сборок в кэше Xcode и Gradle больше нет. Новому разработчику теперь хватает клонирования, установки зависимостей и запуска: собирать окружение под конкретную машину не нужно, и вопросов «а как это у вас запускается» больше не возникает.
Навигация
Старая навигация держалась на пяти пакетах @react-navigation/* и занимала около 970 строк в слое 2_processes/navigation.
2_processes/navigationExpo 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-бандл ещё инициализируется, сессия восстанавливается из хранилища, играет видео-сплэш. Если сразу перейти по ссылке, навигация сломается: стек не готов.
За ссылки отвечает хук 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.
AsyncStorage → MMKV
redux-persist работал поверх AsyncStorage, а тот ходит через асинхронный JS-мост. Из-за этого на старте интерфейс мерцал: приложение не знало, авторизован ли пользователь, пока не разрешится промис чтения токена. MMKV написан на C++ и работает через JSI, поэтому читает данные синхронно.
Токен в 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, а гард в корневой лейаут, делать ему стало нечего, и я его удалил. Слоёв стало шесть.
2_processes удалён: роутинг переехал в Expo Router.Вторая проблема — циклические импорты между мелкими фичами и сущностями. Я собрал код вокруг крупных доменов (task, message, regulation). Каждый домен держит свои API-запросы с WebSocket-подписками, локальный стор и вспомогательные компоненты. Фичи и виджеты импортируют домены сверху вниз, а домены друг от друга не зависят, поэтому циклы ушли.
Заодно я прошёлся по 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+ страниц, по ощущениям, открываются быстрее.
7_sharedshared/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:
Баг на 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.
Цена и риски
Миграция с нуля не бесплатна:
- Ограничения экосистемы. Часть нативных модулей могла не перенестись из-за того, что Expo их не поддерживает. Экосистема сейчас стабильная, и сложных нативных модулей, которым не нашлось бы замены, по ходу работы не обнаружилось. На проекте с кастомным нативным кодом такой подход пришлось бы оценивать отдельно.
- Продукт не стоял на месте. Перед переписыванием я довёл старую версию до ума и вычистил из неё баги, чтобы у пользователей было меньше поводов отвлекать меня от миграции. Всё это время старая версия жила в
master, а новая в отдельной веткеexpo-migration. Старый функционал я продолжал поддерживать вmasterвсё время миграции, так что приходилось держать в голове две кодовые базы.
Итог
Цифры: удалено более 33 000 строк, добавлено 16 700, итого минус 16 810.
- Онбординг. Новичку не нужно возиться с нативным окружением и разбираться в самописной навигации: Expo, Zustand и React Query знает любой React-разработчик.
- Стейт. Клиентское и серверное состояния разделены: Zustand хранит лёгкие локальные настройки, а кэшем, пагинацией и инвалидацией сетевых данных занимается React Query.
- Релизы. Сборка и выкладка в сторы идут одной командой через Fastlane, а версия и changelog считаются автоматически.
В проект больше не страшно заходить. Работы над функциональностью впереди ещё много, но фундамент под неё чистый.