Поделиться
Поделиться

SaaS-приложение на React — это не просто «большой лендинг». Это продукт, который будет использоваться тысячами пользователей одновременно, обновляться каждую неделю и расти вместе с бизнесом. Архитектурные решения, принятые в начале, будут влиять на скорость разработки ещё долгие годы. Разберём, как выстроить проект правильно с самого старта.

Структура проекта: feature-based подход

Классическая «техническая» структура (components/, hooks/, utils/) хорошо работает до ~20 файлов. Дальше она превращается в хаос: чтобы понять, что делает одна фича, приходится прыгать по пяти папкам.

Feature-based структура группирует файлы по функциональному домену:

src/
├── features/
│   ├── auth/
│   │   ├── components/   LoginForm.tsx, SignupForm.tsx
│   │   ├── hooks/        useAuth.ts, usePermissions.ts
│   │   ├── api/          auth.api.ts
│   │   └── index.ts      # публичный API фичи
│   ├── billing/
│   │   ├── components/   PricingTable.tsx, InvoiceList.tsx
│   │   ├── hooks/        useSubscription.ts
│   │   └── index.ts
│   └── workspace/
│       ├── components/
│       ├── store/        workspace.slice.ts
│       └── index.ts
├── shared/
│   ├── ui/               Button, Input, Modal — переиспользуемые компоненты
│   ├── lib/              axios instance, formatters, validators
│   └── types/            глобальные TypeScript-типы
└── app/                  маршруты (React Router / Next.js)

Ключевое правило: фича не импортирует из другой фичи напрямую. Только через index.ts. Это создаёт чёткие границы и упрощает рефакторинг.

Управление состоянием

Zustand — для простых глобальных данных

Zustand минималистичен и не требует провайдеров. Идеален для UI-состояния: тема, sidebar, модальные окна.

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

interface UIStore {
  sidebarOpen: boolean
  theme: 'light' | 'dark'
  toggleSidebar: () => void
  setTheme: (theme: 'light' | 'dark') => void
}

export const useUIStore = create<UIStore>()(
  devtools(
    persist(
      (set) => ({
        sidebarOpen: true,
        theme: 'light',
        toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
        setTheme: (theme) => set({ theme })
      }),
      { name: 'ui-storage' }
    )
  )
)

Redux Toolkit — для сложной доменной логики

RTK оправдан, когда состояние сложное, мутации затрагивают несколько слайсов, или нужна мощная DevTools-отладка.

// features/workspace/store/workspace.slice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

export const fetchWorkspaces = createAsyncThunk(
  'workspace/fetchAll',
  async (_, { rejectWithValue }) => {
    try {
      return await workspaceApi.getAll()
    } catch (err) {
      return rejectWithValue(err.message)
    }
  }
)

const workspaceSlice = createSlice({
  name: 'workspace',
  initialState: { items: [], status: 'idle' as 'idle' | 'loading' | 'succeeded' | 'failed' },
  reducers: {
    setActiveWorkspace: (state, action) => {
      state.activeId = action.payload
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchWorkspaces.pending, (state) => { state.status = 'loading' })
      .addCase(fetchWorkspaces.fulfilled, (state, action) => {
        state.status = 'succeeded'
        state.items = action.payload
      })
  }
})

React Query — для серверного состояния

Главная ошибка — хранить данные с сервера (список пользователей, транзакции, настройки) в Redux. Это ваш кеш, а не состояние приложения. React Query (TanStack Query) управляет этим намного лучше:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// Запрос данных
export function useInvoices() {
  return useQuery({
    queryKey: ['invoices'],
    queryFn: billingApi.getInvoices,
    staleTime: 5 * 60 * 1000 // данные актуальны 5 минут
  })
}

// Мутация с оптимистичным обновлением
export function useUpdateInvoice() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: billingApi.updateInvoice,
    onMutate: async (updatedInvoice) => {
      await queryClient.cancelQueries({ queryKey: ['invoices'] })
      const previous = queryClient.getQueryData(['invoices'])
      queryClient.setQueryData(['invoices'], (old) =>
        old.map(inv => inv.id === updatedInvoice.id ? updatedInvoice : inv)
      )
      return { previous }
    },
    onError: (_, __, context) => {
      queryClient.setQueryData(['invoices'], context.previous)
    },
    onSettled: () => queryClient.invalidateQueries({ queryKey: ['invoices'] })
  })
}

Code Splitting и производительность

Каждая загружаемая страница должна содержать только тот JS, который нужен именно для неё. React.lazy + Suspense — базовый инструмент:

import { lazy, Suspense } from 'react'

const BillingPage = lazy(() => import('./features/billing/pages/BillingPage'))
const AnalyticsPage = lazy(() => import('./features/analytics/pages/AnalyticsPage'))

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/billing" element={<BillingPage />} />
        <Route path="/analytics" element={<AnalyticsPage />} />
      </Routes>
    </Suspense>
  )
}

Анализ бандла

Используйте rollup-plugin-visualizer (для Vite) или @next/bundle-analyzer (для Next.js) чтобы увидеть, что занимает место:

// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default {
  plugins: [
    visualizer({ open: true, gzipSize: true })
  ]
}

Типичные «тяжёлые» зависимости, которые стоит заменить: moment.jsdate-fns, lodash → отдельные импорты, axiosky или нативный fetch.

Multi-tenancy: паттерны UI

В SaaS каждый клиент — отдельный «тенант» со своими данными, настройками и часто брендингом. На уровне фронтенда это решается через контекст:

interface Tenant {
  id: string
  name: string
  plan: 'starter' | 'pro' | 'enterprise'
  branding: { primaryColor: string; logoUrl: string }
  features: string[] // список включённых фич
}

const TenantContext = createContext<Tenant | null>(null)

export function TenantProvider({ children }: { children: React.ReactNode }) {
  const { data: tenant } = useQuery({
    queryKey: ['tenant'],
    queryFn: tenantApi.getCurrent
  })

  if (!tenant) return <FullPageLoader />
  return <TenantContext.Provider value={tenant}>{children}</TenantContext.Provider>
}

// Хук для проверки доступа к фиче
export function useFeature(feature: string) {
  const tenant = useContext(TenantContext)
  return tenant?.features.includes(feature) ?? false
}

// Использование
function ExportButton() {
  const canExport = useFeature('csv-export')
  if (!canExport) return null
  return <Button>Экспорт CSV</Button>
}

CSS-переменные для динамического брендинга

function BrandingApplicator() {
  const tenant = useTenant()

  useEffect(() => {
    document.documentElement.style.setProperty(
      '--color-primary', tenant.branding.primaryColor
    )
  }, [tenant.branding.primaryColor])

  return null
}

Потоки аутентификации

SaaS обычно требует несколько сценариев: email/пароль, OAuth (Google, GitHub), приглашение по ссылке, SSO для enterprise.

Изолируйте всю логику в отдельном провайдере:

// features/auth/AuthProvider.tsx
export function AuthProvider({ children }) {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const unsubscribe = authService.onAuthStateChange((user) => {
      setUser(user)
      setLoading(false)
    })
    return unsubscribe
  }, [])

  if (loading) return <FullPageLoader />

  return (
    <AuthContext.Provider value={{ user, signIn, signOut, signUp }}>
      {children}
    </AuthContext.Provider>
  )
}

Защищённые маршруты через компонент-обёртку:

function ProtectedRoute({ children, requiredPlan }: {
  children: React.ReactNode
  requiredPlan?: 'pro' | 'enterprise'
}) {
  const { user } = useAuth()
  const { data: tenant } = useTenant()

  if (!user) return <Navigate to="/login" replace />

  if (requiredPlan && !hasPlan(tenant, requiredPlan)) {
    return <UpgradePrompt requiredPlan={requiredPlan} />
  }

  return <>{children}</>
}

Таблица: когда что выбирать

| Задача | Решение | |---|---| | UI-состояние (тема, модалки) | Zustand | | Сложная доменная логика | Redux Toolkit | | Данные с сервера | React Query | | Формы | React Hook Form + Zod | | Стили | Tailwind CSS + shadcn/ui | | Тесты компонентов | Vitest + Testing Library | | E2E тесты | Playwright |

Итог

Хорошая архитектура SaaS-приложения — это про предсказуемость. Разработчик, открывая незнакомый файл, должен сразу понимать, к какой фиче он относится и как с ним работать. Feature-based структура, разделение серверного и клиентского состояния, code splitting и чёткие границы между модулями — это не оверинжиниринг, а инвестиция в скорость команды через полгода.

О разработке с помощью AI-ассистентов, которые помогают ускорить работу над такими проектами, читайте в статье Разработка с AI.