Урок 20: Создаем мини-приложение (интерфейс блога)

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

20-й урок является последним. Он позволит вам применить на практике все полученные навыки, от создания слайсов и работы с асинхронными запросами до структурирования проекта и использования TypeScript. Если вы где-то запнетесь, не беда. Это абсолютно нормально. Возвращайтесь к предыдущим урокам, освежайте знания. Цель этого занятия не просто скопировать код, а понять, как все части пазла под названием «Redux Toolkit» складываются в единую картину.

Архитектура нашего блога

Прежде чем писать код, давайте продумаем, что мы хотим получить в итоге. Наше приложение будет отображать список постов, для каждого поста показывать автора (пользователя), а также позволять просматривать и добавлять комментарии. Это классическая структура, которая встречается в бесчисленном количестве веб-приложений.

Мы будем использовать публичное API JSONPlaceholder. Он идеально подходит для таких учебных проектов. От него мы получим три основные сущности:

  1. Посты (/posts). Заголовок, тело поста и ID пользователя.

  2. Пользователи (/users). Имя, username и другие данные.

  3. Комментарии (/comments). Текст комментария, email автора и ID поста, к которому он привязан.

Главная задача, не просто загрузить эти данные, а умело их связать между собой. Например, когда мы получаем пост, мы должны по userId найти соответствующего пользователя и подставить его имя. А при отображении поста, фильтровать комментарии по postId. Это та самая «комбинация состояния» из нескольких слайсов, которую мы рассматривали.

Техническое задание для самостоятельной реализации

Я предлагаю вам следующее ТЗ. Попробуйте реализовать его самостоятельно, используя наши прошлые уроки как шпаргалку. Ниже я дам подсказки и примеры кода, но старайтесь сначала сделать сами.

Нужно создать SPA (Single Page Application) «Блог» с использованием React, TypeScript, Redux Toolkit и RTK Query.

Функциональные требования

  • Страница списка постов:

    1. Отображает карточки всех постов.

    2. В каждой карточке должен быть заголовок, краткое содержание (первые 100 символов тела поста), имя автора и количество комментариев.

    3. Должна быть реализована фильтрация постов по имени пользователя (выпадающий список с пользователями).

  • Страница отдельного поста (при клике на карточку):

    1. Отображает полный текст поста, имя автора и список всех комментариев к этому посту.

    2. Под списком комментариев должна быть форма для добавления нового комментария (поля: nameemailbody). Так как JSONPlaceholder не сохраняет данные по-настоящему, после отправки форма просто очищается, а новый комментарий добавляется в список локально (в стейт Redux).

  • Общее:

    1. Реализовать индикаторы загрузки и обработку ошибок при запросах.

    2. Все асинхронные операции (получение постов, пользователей, комментариев) должны выполняться через RTK Query.

Структура проекта

Для такого проекта организация кода, это половина успеха. Давайте сразу делать это правильно, по принципу feature-based folders (функциональные папки). Это означает, что мы группируем всю логику (слайсы, компоненты, стили, типы) вокруг фич, а не типов файлов.

Вот как может выглядеть наша структура папок в src:

text
src/
│
├── app/
│   ├── store.ts          # Централизованная настройка store
│   └── hooks.ts          # Типизированные хуки useDispatch и useSelector
│
├── features/
│   ├── api/
│   │   └── blogApi.ts    # Основной API slice, созданный с помощью createApi
│   │
│   ├── posts/
│   │   ├── PostsList.tsx
│   │   ├── PostCard.tsx
│   │   ├── PostDetails.tsx
│   │   └── postsSlice.ts # (Опционально) для локального состояния, например, фильтров
│   │
│   ├── users/
│   │   ├── UserSelect.tsx
│   │   └── usersSlice.ts # (Опционально)
│   │
│   └── comments/
│       ├── CommentsList.tsx
│       ├── AddCommentForm.tsx
│       └── commentsSlice.ts # Для локального управления добавленными комментариями
│
├── shared/
│   └── types.ts          # Общие типы TypeScript для всего приложения
│
└── main.tsx

Почему так удобно? Представьте, что вам нужно изменить логику работы с комментариями. Вместо того чтобы бегать по разным папкам (/slices/components/pages), вы открываете одну папку features/comments и находите там все необходимое.

RTK Query как стержень данных. Обратите внимание на папку features/api/blogApi.ts. Вся работа с бэкендом будет сосредоточена здесь. Мы создадим один большой createApi эндпоинт, который будет описывать все наши запросы к JSONPlaceholder.

Пишем код вместе

Давайте набросаем ключевые части приложения. Я покажу примеры, которые помогут вам стартовать.

1. Настраиваем Store и основной API

Сначала создадим наш store и основной API-слайс.

app/store.ts

typescript
import { configureStore } from '@reduxjs/toolkit';
import { blogApi } from '../features/api/blogApi';
import commentsReducer from '../features/comments/commentsSlice'; // Импортируем редьюсер для локальных комментариев

export const store = configureStore({
  reducer: {
    // Добавляем редьюсер из RTK Query
    [blogApi.reducerPath]: blogApi.reducer,
    // Наш собственный редьюсер для локальных комментариев
    comments: commentsReducer,
  },
  // Подключаем middleware для кэширования и инвалидации
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(blogApi.middleware),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

features/api/blogApi.ts

Это сердце нашего приложения. Здесь мы описываем все данные, которые будем получать.

typescript
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

// Определяем типы для наших сущностей
export interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

export interface User {
  id: number;
  name: string;
  username: string;
  email: string;
}

export interface Comment {
  id: number;
  postId: number;
  name: string;
  email: string;
  body: string;
}

export const blogApi = createApi({
  reducerPath: 'blogApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com/',
  }),
  tagTypes: ['Post', 'User', 'Comment'], // Теги для инвалидации кэша
  endpoints: (builder) => ({
    // Получение всех постов
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: (result) =>
        // Предоставляем теги для полученных постов
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Post' as const, id })),
              { type: 'Post', id: 'LIST' },
            ]
          : [{ type: 'Post', id: 'LIST' }],
    }),
    // Получение поста по ID
    getPostById: builder.query<Post, number>({
      query: (id) => `/posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
    // Получение всех пользователей
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      providesTags: ['User'],
    }),
    // Получение пользователя по ID
    getUserById: builder.query<User, number>({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }],
    }),
    // Получение комментариев для конкретного поста
    getCommentsByPostId: builder.query<Comment[], number>({
      query: (postId) => `/comments?postId=${postId}`,
      providesTags: (result, error, postId) => [{ type: 'Comment', postId }],
    }),
    // Добавление нового комментария (будет работать "вхолостую")
    addComment: builder.mutation<Comment, Partial<Comment>>({
      query: (newComment) => ({
        url: '/comments',
        method: 'POST',
        body: newComment,
      }),
      // Инвалидируем кэш комментариев для конкретного поста, чтобы теоретически перезапросить их
      invalidatesTags: (result, error, { postId }) => [
        { type: 'Comment', postId },
      ],
    }),
  }),
});

// Экспортируем автоматически сгенерированные хуки
export const {
  useGetPostsQuery,
  useGetPostByIdQuery,
  useGetUsersQuery,
  useGetUserByIdQuery,
  useGetCommentsByPostIdQuery,
  useAddCommentMutation,
} = blogApi;

2. Локальное состояние для комментариев

Поскольку JSONPlaceholder не сохраняет наши новые комментарии, мы будем хранить их в своем слайсе. Это покажет, как совмещать RTK Query и «классические» слайсы.

features/comments/commentsSlice.ts

typescript
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Comment } from '../api/blogApi';

interface CommentsState {
  localComments: Comment[]; // Массив для хранения добавленных нами комментариев
}

const initialState: CommentsState = {
  localComments: [],
};

const commentsSlice = createSlice({
  name: 'comments',
  initialState,
  reducers: {
    // Редьюсер для добавления нового комментария в локальный массив
    addLocalComment: (state, action: PayloadAction<Comment>) => {
      // Генерируем временный ID, чтобы не было конфликтов с серверными
      const tempId = Math.random() * 1000000;
      state.localComments.push({ ...action.payload, id: tempId });
    },
  },
});

export const { addLocalComment } = commentsSlice.actions;
export default commentsSlice.reducer;

3. Компонент списка постов с фильтрацией

Теперь создадим главную страницу. Мы будем использовать хуки из blogApi и useSelector для доступа к локальным комментариям.

features/posts/PostsList.tsx

typescript
import React, { useState, useMemo } from 'react';
import { useGetPostsQuery, useGetUsersQuery, Post } from '../api/blogApi';
import { useAppSelector } from '../../../app/hooks';
import PostCard from './PostCard';
import UserSelect from '../../users/UserSelect';

const PostsList: React.FC = () => {
  const { data: posts, isLoading, error } = useGetPostsQuery();
  const { data: users } = useGetUsersQuery();
  const [selectedUserId, setSelectedUserId] = useState<number | null>(null);

  // Получаем локальные комментарии из store
  const localComments = useAppSelector((state) => state.comments.localComments);

  // Мемоизированный вычисленный список постов с фильтрацией
  const filteredPosts = useMemo(() => {
    if (!posts) return [];
    let filtered = posts;
    if (selectedUserId) {
      filtered = posts.filter((post) => post.userId === selectedUserId);
    }
    // Обогащаем посты информацией о количестве комментариев
    return filtered.map((post) => {
      const serverCommentsCount = 5; // JSONPlaceholder всегда возвращает 5 комментов для поста. В реальном API мы бы использовали useGetCommentsByPostIdQuery.
      const localCommentsForPost = localComments.filter((comment) => comment.postId === post.id);
      const totalCommentsCount = serverCommentsCount + localCommentsForPost.length;
      return {
        ...post,
        commentCount: totalCommentsCount,
      };
    });
  }, [posts, selectedUserId, localComments]);

  if (isLoading) return <div>Загрузка постов...</div>;
  if (error) return <div>Ошибка при загрузке постов!</div>;

  return (
    <div>
      <h1>Все посты</h1>
      {/* Компонент выбора пользователя для фильтрации */}
      <UserSelect
        users={users || []}
        selectedUserId={selectedUserId}
        onSelectUser={(id) => setSelectedUserId(id)}
      />
      <div className="posts-grid">
        {filteredPosts.map((post) => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
    </div>
  );
};

export default PostsList;

Этот компонент демонстрирует всю суть селекторов и мемоизации. Мы динамически фильтруем и обогащаем данные, полученные из API, данными из нашего локального состояния.

Итоги все курса по Redux и Redux Toolkit

Мы начали с простого useState и проблемы прокидывания пропсов, изучили базовые концепции Redux, а затем открыли для себя Redux Toolkit. Мы научились создавать слайсы, работать с асинхронностью через createAsyncThunk и освоили вершину удобства, это RTK Query для бесшовной работы с API.

Главные преимущества Redux Toolkit, которые вы теперь ощутили на себе:

  • Меньше шаблонного кода. createSlice сам генерирует экшены и редьюсеры.

  • Встроенные лучшие практики. Immer для иммутабельных обновлений, configureStore с подключенными DevTools по умолчанию.

  • Удобство RTK Query. Интуитивное управление кэшированием, инвалидацией и сетевыми запросами, которое практически не требует написания кода.

  • Предсказуемость и отладка. Redux DevTools остаются вашим верным союзником, позволяя отслеживать каждое изменение состояния и «путешествовать во времени».

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

Куда двигаться дальше?

  1. redux-persist. Эта библиотека позволяет сохранять состояние вашего Redux-стора в localStorage (или другом хранилище). После перезагрузки страницы приложение «вспомнит» все данные (например, корзину покупок, токен авторизации или, как в нашем случае, локально добавленные комментарии). Это следующий логичный шаг для улучшения UX.

  2. Изучение соседних технологий. Углубитесь в TypeScript, изучите современные фреймворки, такие как Next.js, который отлично дружит с RTK Query.

  3. Оптимизация производительности. Изучите, как работают мемоизированные селекторы с createSelector, чтобы предотвратить лишние перерисовки компонентов.

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

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

Полный курс с уроками по Redux и Redux Toolkit для начинающих.

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий