Блог

31.03.2024

Понимание управления состоянием в Next.js

nextjs

Примечание редактора: Эта статья была проверена на корректность 16 марта 2024 года Элайджей Асаолу и обновлена с учетом последних изменений в экосистеме React и Next.js, связанных с управлением состояниями.

Управление состояниями является основой любого современного веб-приложения, поскольку оно определяет, какие данные будут отображаться на экране во время сессии использования приложения, пока пользователь взаимодействует с ним. Например, можно рассмотреть такие функции, как установка флажков в онлайн-опросах, добавление товаров в корзину в магазине электронной коммерции или выбор звука из плейлиста в музыкальном плеере. Все это возможно благодаря управлению состояниями, отслеживающему каждое действие пользователя.

В этой статье мы рассмотрим множество методов управления состояниями, которые вы можете использовать для отслеживания состояний в своих приложениях Next.js. Для начала мы рассмотрим распространенные методы управления состояниями в традиционном React.js и то, как реализовать их в Next.js. Затем мы рассмотрим более сложные методы управления состояниями, такие как промежуточное ПО, и то, как управление состояниями осуществляется в компонентах React Server Components (RSC). Для каждого решения я приведу практический пример, чтобы было легко понять, как работает каждый подход. Мы будем использовать подход 'сверху вниз', рассматривая сначала самые простые методы и переходя к более продвинутым решениям для более сложных случаев использования.

Как работает состояние?

Состояние - это объект JavaScript, который хранит текущее состояние некоторых данных. Вы можете представить его как выключатель света, который может иметь состояние 'включено' или 'выключено'. Теперь перенесите тот же принцип на экосистему React и представьте себе использование тумблера светлого и темного режима. Каждый раз, когда пользователь нажимает на тумблер, активируется противоположное состояние. Это состояние затем обновляется в объекте состояния JavaScript. В результате ваше приложение знает, какое состояние активно в данный момент и какую тему отображать на экране. Независимо от того, как приложение управляет своими данными, состояние всегда должно передаваться от родительского элемента к дочерним элементам.

Понимание структуры файлов Next.js в версии 13 и последующих версиях.

Прежде чем погрузиться в структуру файлов, давайте создадим новое приложение Next.js. Откройте терминал и выполните следующую команду, заменив project-name на желаемое имя проекта:

npx create-next-app@latest [project-name]

Эта команда создаст для нас новый каталог проекта с именем [project-name]. Во время установки вам будет предложено интерактивное меню, в котором вы сможете выбрать предпочтительную версию Next.js, решить, хотите ли вы использовать TypeScript для обеспечения безопасности типов, выбрать предварительно настроенный CSS-фреймворк, например Bootstrap или Tailwind CSS, и многое другое. После завершения установки ваш проект будет иметь определенную структуру файлов, которая упрощает разработку. Давайте рассмотрим ключевые папки и их назначение:

корневой уровень:это главная директория вашего приложения Next.js. Как правило, он содержит основные файлы конфигурации, такие как package.json и next.config.js

каталог app (представлен в Next 13):В Next 13 появилась директория app, в которой хранится вся логика приложения, включая компоненты, макеты и маршруты. Эта директория заменяет предыдущую директорию pages для лучшей организации и масштабируемости.

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

каталог styles:В этой директории хранятся глобальные стили CSS, которые применяются во всем приложении

каталог pages (опционально в Next 13):хотя каталог app занимает центральное место в Next 13, каталог pages остается полезным для старых проектов или ситуаций, когда вы предпочитаете маршрутизацию на основе файлов. Однако для вновь созданных приложений рекомендуется использовать каталог app.

Если вы используете каталог app и ваш компонент взаимодействует с окружением браузера (например, использует хуки типа useState или useEffect для взаимодействия на стороне клиента), вам нужно включить use client в верхней части файла компонента. Это даст указание Next.js рассматривать компонент как компонент на стороне клиента, обеспечивая его корректное отображение и функционирование в браузере.

Понимание структуры файлов Next.js в версии 13 и последующих версиях.

Давайте начнем с основ управления состоянием в Next.js. В последующих разделах мы поговорим об основных хуках, техниках и инструментах, которые можно использовать для эффективного управления состоянием.

Использование хука useState для управления состоянием в Next.js

Хук useState - это популярный метод управления состоянием в традиционных приложениях React, и в Next.js он работает аналогично. Чтобы начать, давайте создадим приложение, которое позволит пользователям увеличить свой счет, нажав на кнопку. Перейдите на страницы и включите следующий код в index.js:


// 'use client' // if using /app folder
import { useState } from "react";

export default function Home() {
  const [score, setScore] = useState(0);
  const increaseScore = () => setScore(score + 1);

  return (
    <div>
      <p>Your score is {score}</p>
      <button onClick={increaseScore}>+</button>
    </div>
  );
}

Сначала мы импортировали сам хук useState, затем установили начальное состояние равным 0. Мы также создали функцию setScore, чтобы впоследствии можно было обновить счет. Затем мы создали функцию increaseScore, которая получает доступ к текущему значению оценки и с помощью setState увеличивает его на 1. Мы назначили эту функцию событию onClick для кнопки +, поэтому при каждом нажатии кнопки оценка увеличивается.

Хук useReducer

Хук useReducer работает аналогично методу reduce для массивов. Мы передаем функцию reducer и начальное значение. Редуктор получает текущее состояние и действие и возвращает новое состояние. Мы создадим приложение, которое позволит вам умножить текущий активный результат на 2. Включите следующий код в index.js:


// 'use client' // if using /app folder

import { useReducer } from "react";

export default function Home() {
  const [multiplication, dispatch] = useReducer((state, action) => {
    return state * action;
  }, 50);
  return (
    <div>
      <p>The result is {multiplication}</p>
      <button onClick={() => dispatch(2)}>Multiply by 2</button>
    </div>
  );
}

Сначала мы импортировали сам хук useReducer. Мы передали в него функцию reducer и начальное состояние. Затем хук вернул массив из текущего состояния и функции диспетчеризации. Мы передали функцию dispatch в событие onClick, чтобы значение текущего состояния умножалось на 2 при каждом нажатии на кнопку, устанавливая следующие значения: 100, 200, 400, 800, 1600 и так далее.

Техника prop drilling для управления состояниями в Next.js

В более продвинутых приложениях вы не будете работать с состояниями непосредственно в одном файле. Скорее всего, вы разделите код на различные компоненты, чтобы было проще масштабировать и поддерживать приложение. Как только появляется несколько компонентов, состояние необходимо передавать с родительского уровня на дочерние. Эта техника называется prop drilling, и она может быть многоуровневой. В этом уроке мы создадим базовый пример глубиной всего в два уровня, чтобы дать вам представление о том, как работает prop drilling. Включите следующий код в файл index.js:


// 'use client' // if using /app folder

import { useState } from "react";

const Message = ({ active }) => {
  return <h1>The switch is {active ? "active" : "disabled"}</h1>;
};

const Button = ({ onToggle }) => {
  return <button onClick={onToggle}>Change</button>;
};

const Switch = ({ active, onToggle }) => {
  return (
    <div>
      <Message active={active} />
      <Button onToggle={onToggle} />
    </div>
  );
};

export default function Home() {
  const [active, setActive] = useState(false);
  const toggle = () => setActive((active) => !active);

  return <Switch active={active} onToggle={toggle} />;
}

В приведенном выше фрагменте кода сам компонент Switch не нуждается в значениях active и toggle, но мы должны 'пробурить' компонент и передать эти значения дочерним компонентам Message и Button, которым они нужны.

Использование Context API в Next.js

Хуки useState и useReducer в сочетании с техникой "prop drilling" покроют множество случаев использования для большинства базовых приложений, которые вы создаете. Но что, если ваше приложение намного сложнее, props должны передаваться на несколько уровней вниз, или у вас есть некоторые состояния, которые должны быть доступны глобально? В этом случае рекомендуется избегать "prop drilling" и использовать Context API, который позволит вам получить глобальный доступ к состоянию. Всегда рекомендуется создавать отдельные контексты для различных состояний, таких как аутентификация, пользовательские данные и так далее. Мы создадим пример управления состоянием темы. Сначала создадим отдельную папку в корне сайта и назовем ее context. Внутри нее создайте новый файл theme.js и включите в него следующий код:


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

const Context = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");
  return (
    <Context.Provider value={[theme, setTheme]}>{children}</Context.Provider>
  );
}

export function useThemeContext() {
  return useContext(Context);
}

Сначала мы создали новый объект Context, создали функцию ThemeProvider и установили начальное значение для Context - light. Затем мы создали пользовательский хук useThemeContext, который позволит нам получить доступ к состоянию темы после того, как мы импортируем ее на отдельные страницы или компоненты нашего приложения. Далее нам нужно обернуть ThemeProvider вокруг всего приложения, чтобы мы могли получить доступ к состоянию темы во всем приложении. Перейдите в файл _app.js и включите в него следующий код:


import { ThemeProvider } from "../context/theme";

export default function MyApp({ Component, pageProps }) {
  return (
    <ThemeProvider>
      <Component {...pageProps} />
    </ThemeProvider>
  );
}

Чтобы получить доступ к состоянию темы, перейдите в файл index.js и включите в него следующий код:


// 'use client' // if using /app folder

import Link from "next/link";
import { useThemeContext } from "../context/theme";

export default function Home() {
  const [theme, setTheme] = useThemeContext();
  return (
    <div>
      <h1>Welcome to the Home page</h1>
      <Link href="/about">
        <a>About</a>
      </Link>
      <p>Current mode: {theme}</p>
      <button
        onClick={() => {
          theme == "light" ? setTheme("dark") : setTheme("light");
        }}
      >
        Toggle mode
      </button>
    </div>
  );
}

Сначала мы импортировали useThemeContext, затем получили доступ к состоянию темы и функции setTheme, чтобы обновить его при необходимости. Внутри события onClick кнопки переключения мы создали функцию обновления, которая переключает противоположные значения между светлым и темным в зависимости от текущего значения.

Доступ к контексту через маршруты

Next.js использует папку pages для создания новых маршрутов в вашем приложении. Например, если вы создадите новый файл route.js, а затем обратитесь к нему откуда-нибудь через компонент Link, он будет доступен через /route в вашем URL. В предыдущем фрагменте кода мы создали маршрут к маршруту About. Это позволит нам проверить, что состояние темы доступно глобально. В настоящее время этот маршрут не существует, поэтому давайте создадим новый файл about.js в папке pages и включим в него следующий код:


// 'use client' // if using /app folder

import Link from "next/link";
import { useThemeContext } from "../context/theme";

export default function Home() {
  const [theme, setTheme] = useThemeContext();
  return (
    <div>
      <h1>Welcome to the About page</h1>
      <Link href="/">
        <a>Home</a>
      </Link>
      <p>Currently active theme: {theme}</p>
      <button
        onClick={() => {
          theme == "light" ? setTheme("dark") : setTheme("light");
        }}
      >
        Toggle mode
      </button>
    </div>
  );
}

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

Получение данных из API

Предыдущие методы будут работать при внутреннем управлении состояниями в приложении. Однако в реальной жизни вы, скорее всего, будете получать данные из внешних источников через API. Вкратце получение данных можно описать как выполнение запроса к конечной точке API и получение данных после обработки запроса и отправки ответа. Нужно учитывать, что этот процесс не является мгновенным, поэтому нам нужно управлять состояниями ответа, например состоянием ожидания, пока ответ готовится. Мы также будем обрабатывать случаи возможных ошибок. Отслеживание состояния ожидания позволяет нам отображать анимацию загрузки для улучшения UX, а состояние ошибки дает нам знать, что ответ был неудачным. Это позволит нам вывести сообщение об ошибке, дающее дополнительную информацию о ее причине.

API Fetch и хук useEffect

Одним из наиболее распространенных способов обработки данных является использование комбинации родного Fetch API и хука useEffect Hook. Хук useEffect позволяет нам выполнять побочные эффекты после завершения какого-либо другого действия. С его помощью мы можем отследить, когда приложение было отрисовано и можно смело выполнять вызов выборки. Чтобы получить данные в Next.js, преобразуйте index.js следующим образом:


// 'use client' // if using /app folder

import { useState, useEffect } from "react";

export default function Home() {
  const [data, setData] = useState(null)
  const [isLoading, setLoading] = useState(false)

  useEffect(() => {
    setLoading(true)
    fetch('api/book')
      .then((res) => res.json())
      .then((data) => {
        setData(data)
        setLoading(false)
      })
  }, [])
  if (isLoading) return <p>Loading book data...</p>
  if (!data) return <p>No book found</p>

  return (
    <div>
      <h1>My favorite book:</h1>
      <h2>{data.title}</h2>
      <p>{data.author}</p>
    </div>
  )
}

Сначала мы импортировали хуки useState и useEffect. Затем мы создали отдельные начальные состояния для полученных данных - null и времени загрузки - false, указывающие на то, что вызов выборки не был выполнен. После рендеринга приложения мы устанавливаем состояние для загрузки в true и создаем вызов fetch. Как только ответ будет получен, мы устанавливаем данные в полученный ответ и возвращаем состояние загрузки в false, указывая, что выборка завершена. Далее нам нужно создать корректную конечную точку API. Перейдите в папку api и создайте в ней новый файл book.js, чтобы у нас появилась конечная точка API, которую мы включили в вызов fetch в предыдущем фрагменте кода. Включите следующий код:


export default function handler(req, res) {
  res
    .status(200)
    .json({ title: "The fault in our stars", author: "John Green" });
}

Этот код имитирует ответ о названии и авторе книги, который вы обычно получаете от какого-нибудь внешнего API, но для данного урока он вполне подойдет.


<h3 id="using-swr-state-management-next-js">Использование SWR для управления состоянием в Next.js</h3>

Существует также альтернативный метод, созданный самой командой Next.js, который позволяет управлять получением данных еще более удобным способом. Он называется SWR - это пользовательская библиотека Hook, которая обрабатывает кэширование, ревалидацию, отслеживание фокуса, повторную выборку с интервалом и многое другое. Чтобы установить SWR, запустите npm install swr в терминале. Чтобы увидеть ее в действии, давайте трансформируем файл index.js:


// 'use client' // if using /app folder

import useSWR from "swr";

export default function Home() {
  const fetcher = (...args) => fetch(...args).then((res) => res.json());
  const { data, error } = useSWR("api/user", fetcher);

  if (error) return <p>No person found</p>;
  if (!data) return <p>Loading...</p>;

  return (
    <div>
      <h1>The winner of the competition:</h1>
      <h2>
        {data.name} {data.surname}
      </h2>
    </div>
  );
}

Использование SWR упрощает многие вещи: синтаксис выглядит чище и легче читается, он хорошо подходит для масштабирования, а ошибки и состояния ответа обрабатываются в паре строк кода. Теперь давайте создадим конечную точку API, чтобы получить ответ. Перейдите в папку api, создайте новый файл user.js и включите в него следующий код:


export default function handler(req, res) {
  res.status(200).json({ name: "Jade", surname: "Summers" });
}

Интеграция Middleware в Next.js

Middleware в Next.js - это мощная функция для управления запросами и ответами в вашем приложении. Она позволяет запускать код до завершения запроса, предоставляя вам гибкий способ манипулирования запросами и ответами и эффективного управления глобальными состояниями. Чтобы начать работу с промежуточным ПО, создайте файл middleware.js в каталоге /pages или /app вашего приложения Next.js. В этом файле вы определите логику работы промежуточного ПО. Вот базовая настройка:


import { NextResponse } from 'next/server';

export function middleware(request) {
  // Your middleware logic goes here
  return NextResponse.next();
}

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


import { NextResponse } from 'next/server';
export function middleware(request) {
  const { pathname } = request.nextUrl;
  // Assuming you have a method to verify authentication
  if (!isUserAuthenticated() && pathname.startsWith('/protected')) {
    return NextResponse.redirect('/login');
  }
  return NextResponse.next();
}

В приведенном выше примере маршрут, который пытается посетить пользователь, определяется с помощью request.nextUrl.pathname. Затем мы используем гипотетический код isUserAuthenticated(), чтобы проверить, аутентифицирован ли пользователь. Если пользователь не авторизован и пытается получить доступ к маршруту, начинающемуся с /protected, NextResponse.redirect('/login') перенаправляет его на страницу входа. Однако если пользователь авторизован или посещает незащищенный маршрут, промежуточное ПО разрешает ему продолжить запрос в обычном режиме с помощью NextResponse.next(). Такой централизованный подход гарантирует, что состояние аутентификации будет последовательно применяться во всех защищенных частях вашего приложения. Еще один распространенный случай использования - управление настройками тем на основе предпочтений пользователя или системных настроек. Middleware позволяет перехватывать запросы и изменять заголовки ответа или cookies, чтобы отразить предпочитаемую пользователем тему:


import { NextResponse } from 'next/server';

export function middleware(request) {
  const preferredTheme = request.cookies.get('theme') || 'light';
  // Modify the response to include the preferred theme
  const response = NextResponse.next();
  response.cookies.set('theme', preferredTheme);
  return response;
}

В этом примере мы проверяем наличие предпочитаемой пользователем темы ("theme") в cookies и возвращаемся к "light", если она не найдена. Перед выполнением запроса мы обновили ответ, чтобы включить желаемую тему в файлы cookie, вызвав response.cookies.set("theme", preferredTheme). Ответ, который теперь содержит предпочитаемую пользователем тему, возвращается, позволяя вашему приложению использовать и управлять состоянием темы в течение всего сеанса пользователя.

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

В следующем 13-м разделе представлены серверные компоненты - новый способ создания высокопроизводительных приложений. Эти компоненты запускаются на сервере во время первоначального запроса, обеспечивая такие преимущества, как ускорение первоначальной загрузки страниц и улучшение SEO. Однако управление состоянием в серверных компонентах отличается от типичных клиентских компонентов React. Прежде чем мы перейдем к рассмотрению этих методов управления состоянием, давайте вкратце рассмотрим, как создавать серверные компоненты и чем они отличаются от традиционных клиентских компонентов. Если вы используете новую директорию /app для маршрутизации, компоненты по умолчанию считаются серверными, что означает, что они запускаются на сервере во время первоначального запроса. Вот базовый пример серверного компонента, который запрашивает данные из базы данных и отображает их:


import { dbConnect } from '@/services/mongo';
import TodoList from './components/TodoList';

export default async function Home() {
  await dbConnect();
  const todos = await dbConnect().collection('todos').find().toArray();

  return (
    <main>
      <TodoList todos={todos} />
    </main>
  );
}

Как показано в примере выше, мы имеем прямой доступ к серверному процессу и можем создать компонент async. Мы также смогли подключиться к базе данных MongoDB непосредственно из компонента, что свойственно серверным фреймворкам и языкам вроде Express и PHP. В этом и заключается вся прелесть серверных компонентов. Однако это также означает, что мы не можем использовать в этих компонентах клиентские хуки, такие как useState() и useEffect(). Хотя серверные компоненты доминируют в маршрутизаторе /app, вам все еще могут понадобиться компоненты на стороне клиента, которые взаимодействуют с окружением браузера. Чтобы создать традиционный компонент на стороне клиента, просто включите оператор 'use client' в верхнюю часть файла компонента, как показано ниже:


'use client'
import { useState} from 'react';

export default function MyClientComponent() {
  const [count, setCount] = useState(0);

  const handleClick = () => setCount(count + 1);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}

Таким образом, вы получаете доступ к традиционным хукам, как обычно, и можете выполнять интерактивность на стороне клиента. Как уже говорилось ранее, серверные компоненты не могут напрямую использовать такие хуки, как useState() или useEffect(), однако вы можете использовать такие библиотеки, как Zustand, для управления состояниями даже на сервере. Тем не менее, как правило, не рекомендуется управлять сложными состояниями приложения на сервере из-за потенциальных последствий для производительности и несоответствия данных. Дополнительную информацию по этому вопросу можно найти на Github. Применяйте серверные механизмы хранения, такие как cookies или сессии, для данных с состояниями, которые должны сохраняться в течение сеансов пользователя. Они идеально подходят для обработки состояний аутентификации, предпочтений пользователей или данных, которые не меняются регулярно. Например, вы можете получить доступ к пользовательским файлам cookie в серверном компоненте, как показано ниже:


import { cookies } from 'next/headers'

export default function Page() {
  const cookieStore = cookies()
  return cookieStore.getAll().map((cookie) => (
    <div key={cookie.name}>
      <p>Name: {cookie.name}</p>
      <p>Value: {cookie.value}</p>
    </div>
  ))
}

Заключение

В этом уроке мы создали несколько мини-приложений, чтобы продемонстрировать множество способов управления состоянием в приложениях Next.js. В каждом случае использовались различные решения для управления состояниями, но самая большая проблема при выборе наиболее подходящего решения для управления состояниями - это умение определить, какие состояния вам нужно отслеживать.

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