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

Документация по управлению состоянием во фронтенд-приложении.

Введение

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

  • Локальное состояние компонентов с использованием React Hooks
  • Глобальное состояние через Context API
  • Состояние форм с использованием React Hook Form
  • Управление состоянием серверных данных с использованием TanStack Query

Локальное состояние (useState)

Для управления локальным состоянием компонентов используется хук useState. Это базовый подход для хранения состояния, которое относится только к конкретному компоненту.

typescript
// Пример использования useState
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";

export default function Counter() {
  // Объявление переменной состояния и функции для её обновления
  const [count, setCount] = useState(0);
  
  // Функция для увеличения счётчика
  const increment = () => setCount(count + 1);
  
  return (
    <div className="flex flex-col items-center gap-4">
      <p className="text-xl">Значение счётчика: {count}</p>
      <Button onClick={increment}>Увеличить</Button>
    </div>
  );
}

Для управления более сложным состоянием можно объединять несколько переменных состояния в объект:

typescript
// Управление формой с использованием useState
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

export default function ProfileForm() {
  const [formData, setFormData] = useState({
    firstName: "",
    lastName: "",
    email: ""
  });
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
  };
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log("Form data:", formData);
    // Дальнейшая обработка...
  };
  
  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <Input 
          name="firstName"
          value={formData.firstName}
          onChange={handleChange}
          placeholder="Имя"
        />
      </div>
      <div>
        <Input 
          name="lastName"
          value={formData.lastName}
          onChange={handleChange}
          placeholder="Фамилия"
        />
      </div>
      <div>
        <Input 
          name="email"
          type="email"
          value={formData.email}
          onChange={handleChange}
          placeholder="Email"
        />
      </div>
      <Button type="submit">Сохранить</Button>
    </form>
  );
}

Глобальное состояние (Context API)

Для управления глобальным состоянием в приложении используется Context API. Главным примером является AuthContext, который управляет состоянием аутентификации пользователя.

typescript
// contexts/AuthContext.tsx
"use client";

import React, { createContext, useContext, useEffect, useState } from "react";

interface User {
  id: number;
  email: string;
  first_name?: string;
  last_name?: string;
  is_verified: boolean;
  oauth_provider?: string;
  oauth_id?: string;
  profile_image_url?: string;
}

interface AuthContextType {
  user: User | null;
  isLoading: boolean;
  isAuthenticated: boolean;
  login: (email: string, password: string) => Promise<void>;
  register: (
    email: string,
    password: string,
    firstName?: string,
    lastName?: string
  ) => Promise<void>;
  logout: () => Promise<void>;
  refreshUser: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  const refreshUser = async () => {
    try {
      const response = await fetch("/api/auth/me");
      if (response.ok) {
        const userData = await response.json();
        setUser(userData);
      } else {
        setUser(null);
      }
    } catch (error) {
      console.error("Error fetching user:", error);
      setUser(null);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    refreshUser();
  }, []);

  const login = async (email: string, password: string) => {
    // Реализация логики входа
    try {
      const response = await fetch("/api/auth/login", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ email, password }),
      });

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || "Login failed");
      }

      await refreshUser();
    } catch (error) {
      console.error("Login error:", error);
      throw error;
    }
  };

  // Другие методы: register, logout, ...

  return (
    <AuthContext.Provider
      value={{
        user,
        isLoading,
        isAuthenticated: !!user,
        login,
        register,
        logout,
        refreshUser,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
}

Для использования AuthContext в компонентах необходимо обернуть приложение в AuthProvider в корневом layout:

typescript
// app/layout.tsx
import { AuthProvider } from "@/contexts/AuthContext";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ru">
      <body>
        <AuthProvider>
          {children}
        </AuthProvider>
      </body>
    </html>
  );
}

Доступ к контексту в компонентах осуществляется через хук useAuth:

typescript
// Пример использования useAuth
"use client";

import { useAuth } from "@/contexts/AuthContext";
import { Button } from "@/components/ui/button";

export default function ProfileButton() {
  const { user, isAuthenticated, isLoading, logout } = useAuth();
  
  if (isLoading) {
    return <p>Загрузка...</p>;
  }
  
  if (!isAuthenticated) {
    return <Button>Войти</Button>;
  }
  
  return (
    <div className="flex gap-2">
      <p>Привет, {user?.first_name || 'Пользователь'}</p>
      <Button variant="outline" onClick={logout}>Выйти</Button>
    </div>
  );
}

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

Для эффективного управления состоянием форм в проекте используется React Hook Form в сочетании с Zod для валидации.

typescript
// Пример формы с React Hook Form и Zod
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";

// Определение схемы валидации с помощью Zod
const formSchema = z.object({
  name: z.string().min(2, "Имя должно содержать не менее 2 символов"),
  email: z.string().email("Введите корректный email"),
  password: z.string().min(8, "Пароль должен содержать не менее 8 символов"),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Пароли не совпадают",
  path: ["confirmPassword"],
});

export default function RegisterForm() {
  // Инициализация формы с использованием useForm
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      name: "",
      email: "",
      password: "",
      confirmPassword: "",
    },
  });

  // Обработчик отправки формы
  const onSubmit = async (values: z.infer<typeof formSchema>) => {
    try {
      // Отправка данных на сервер
      const response = await fetch("/api/auth/register", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          name: values.name,
          email: values.email,
          password: values.password,
        }),
      });

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message);
      }

      toast.success("Регистрация прошла успешно");
      // Дополнительная логика после успешной регистрации
    } catch (error) {
      toast.error(error instanceof Error ? error.message : "Произошла ошибка");
    }
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Имя</FormLabel>
              <FormControl>
                <Input placeholder="Введите ваше имя" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input placeholder="example@email.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Пароль</FormLabel>
              <FormControl>
                <Input type="password" placeholder="••••••••" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="confirmPassword"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Подтверждение пароля</FormLabel>
              <FormControl>
                <Input type="password" placeholder="••••••••" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
          {form.formState.isSubmitting ? "Отправка..." : "Зарегистрироваться"}
        </Button>
      </form>
    </Form>
  );
}

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

Для эффективного управления состоянием данных, получаемых с сервера, в проекте рекомендуется использовать TanStack Query (React Query). Эта библиотека предоставляет мощные механизмы кэширования, обновления и синхронизации данных.

typescript
// lib/queries/users.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import { User, CreateUserInput, UpdateUserInput } from "@/types";

// Ключи запросов для кэширования
export const userKeys = {
  all: ["users"] as const,
  lists: () => [...userKeys.all, "list"] as const,
  list: (filters: Record<string, string>) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, "detail"] as const,
  detail: (id: number) => [...userKeys.details(), id] as const,
};

// Хук для получения списка пользователей
export function useUsers(filters: Record<string, string> = {}) {
  return useQuery({
    queryKey: userKeys.list(filters),
    queryFn: () => api.get<User[]>("/users", { params: filters }),
  });
}

// Хук для получения одного пользователя
export function useUser(id: number) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => api.get<User>(`/users/${id}`),
    enabled: !!id, // Запрос выполняется только если id существует
  });
}

// Хук для создания пользователя
export function useCreateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (data: CreateUserInput) => api.post<User>("/users", data),
    onSuccess: () => {
      // Инвалидируем кэш списка пользователей после успешного создания
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    },
  });
}

// Хук для обновления пользователя
export function useUpdateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: ({ id, data }: { id: number; data: UpdateUserInput }) => 
      api.patch<User>(`/users/${id}`, data),
    onSuccess: (data) => {
      // Обновляем кэш после успешного обновления
      queryClient.invalidateQueries({ queryKey: userKeys.detail(data.id) });
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    },
  });
}

// Хук для удаления пользователя
export function useDeleteUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (id: number) => api.delete<void>(`/users/${id}`),
    onSuccess: (_, id) => {
      // Инвалидируем кэш после успешного удаления
      queryClient.invalidateQueries({ queryKey: userKeys.detail(id) });
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    },
  });
}

Для использования TanStack Query необходимо обернуть приложение в QueryProvider:

typescript
// app/providers.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000, // 1 минута
        refetchOnWindowFocus: false,
      },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      {process.env.NODE_ENV !== "production" && <ReactQueryDevtools />}
    </QueryClientProvider>
  );
}

И добавить его в корневой layout:

typescript
// app/layout.tsx
import { AuthProvider } from "@/contexts/AuthContext";
import { Providers } from "./providers";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ru">
      <body>
        <Providers>
          <AuthProvider>
            {children}
          </AuthProvider>
        </Providers>
      </body>
    </html>
  );
}

Пример использования TanStack Query в компоненте:

typescript
// Пример компонента со списком пользователей
"use client";

import { useUsers } from "@/lib/queries/users";
import { Button } from "@/components/ui/button";
import { useState } from "react";

export default function UsersList() {
  const [page, setPage] = useState(1);
  const { data, isLoading, error } = useUsers({ page: page.toString() });
  
  if (isLoading) {
    return <div>Загрузка...</div>;
  }
  
  if (error) {
    return <div>Ошибка: {error.message}</div>;
  }
  
  return (
    <div className="space-y-4">
      <h2 className="text-xl font-bold">Список пользователей</h2>
      
      <ul className="space-y-2">
        {data?.map(user => (
          <li key={user.id} className="p-2 border rounded">
            {user.first_name} {user.last_name} ({user.email})
          </li>
        ))}
      </ul>
      
      <div className="flex gap-2">
        <Button 
          onClick={() => setPage(p => Math.max(1, p - 1))}
          disabled={page === 1}
        >
          Предыдущая
        </Button>
        <Button 
          onClick={() => setPage(p => p + 1)}
          disabled={!data || data.length === 0}
        >
          Следующая
        </Button>
      </div>
    </div>
  );
}

Рекомендации по управлению состоянием

  • Используйте подходящий уровень состояния: локальное состояние для компонентов, контекст для общих данных между компонентами, React Query для серверных данных.
  • Разделяйте состояние логически: создавайте отдельные контексты для разных доменных областей (auth, user preferences, theme, и т.д.).
  • Избегайте избыточного состояния: храните в состоянии только необходимые данные, вычисляемые значения лучше получать через функции.
  • Используйте мемоизацию: применяйте useMemo и useCallback для оптимизации производительности при работе со сложными вычислениями или колбэк-функциями.
  • Функциональные обновления: используйте форму setState(prev => newState) вместо setState(newState) для предотвращения проблем с устаревшими значениями состояния.
  • Сохраняйте иммутабельность: всегда создавайте новые объекты/массивы при обновлении состояния, не изменяйте существующие напрямую.

Ресурсы