Урок 32 из 49 · Месяц 4. React
ToDoList: axios, json-server и Postman
Содержание урока
- План проекта
- Шаг 1. Создаём проект
- Шаг 2. Поднимаем json-server
- Шаг 3. Проверяем API в Postman
- GET — получить список
- POST — создать задачу
- PATCH — изменить поле
- DELETE — удалить
- Шаг 4. Знакомимся с axios
- Экземпляр axios с базовым адресом
- Шаг 5. Слой API
- Шаг 6. Компоненты
- TodoForm — добавление задачи
- TodoItem — одна задача с редактированием
- App — состояние и запросы
- Разбор App
- Шаг 7. Запуск и проверка
- Типичные ошибки
- Практика
- Итоги
Пора собрать всё изученное в настоящий мини-проект — список задач (ToDoList), который хранит данные на сервере. Мы поднимем учебный сервер json-server за одну минуту, проверим его в Postman, научимся делать запросы через библиотеку axios и сравним её с fetch. В конце у вас будет приложение, где задачи можно добавлять, отмечать, редактировать и удалять — и после перезагрузки страницы они никуда не исчезнут.
План проекта
Браузер (React, порт 5173) ⇄ HTTP-запросы ⇄ json-server (порт 3001) ⇄ файл db.json
Что умеет приложение:
- Загружает список задач с сервера при открытии.
- Добавляет новую задачу.
- Отмечает задачу выполненной.
- Редактирует текст задачи.
- Удаляет задачу.
- Фильтрует: все / активные / выполненные.
Каждое действие — это HTTP-запрос. Вспомните таблицу методов из урока REST API, PUT и DELETE:
| Действие | Метод | Адрес |
|---|---|---|
| Получить все задачи | GET |
/todos |
| Создать задачу | POST |
/todos |
| Изменить часть полей | PATCH |
/todos/:id |
| Удалить задачу | DELETE |
/todos/:id |
Шаг 1. Создаём проект
npm create vite@latest todo-app -- --template react
cd todo-app
npm install
npm install axios
npm install -D json-server
axios— библиотека для HTTP-запросов;json-serverставим как dev-зависимость (-D): он нужен только для разработки.
Очистите src/App.jsx и src/App.css от стартового кода Vite.
Шаг 2. Поднимаем json-server
json-server — это «ненастоящий» сервер для учёбы и прототипов. Вы даёте ему JSON-файл, а он сразу создаёт по нему полноценное REST API: можно получать, добавлять, менять и удалять данные. Все изменения записываются обратно в файл. Пока бэкенд-разработчик пишет настоящий сервер, фронтендер может работать с json-server.
Создайте в корне проекта (рядом с package.json, не в src) файл db.json:
{
"todos": [
{ "id": "1", "title": "Выучить JSX", "completed": true },
{ "id": "2", "title": "Разобраться с useState", "completed": true },
{ "id": "3", "title": "Сделать ToDoList на axios", "completed": false }
]
}
Ключ todos станет адресом /todos. Добавьте в package.json скрипт:
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"server": "json-server db.json --port 3001"
}
}
Запустите сервер в отдельном терминале:
npm run server
Откройте в браузере http://localhost:3001/todos — вы увидите массив задач в формате JSON. Сервер работает!
Шаг 3. Проверяем API в Postman
Postman — программа для отправки HTTP-запросов без написания кода. Это как «пульт» для сервера: выбираете метод, вводите адрес, нажимаете Send и смотрите ответ. Бэкендеры и фронтендеры пользуются им каждый день, чтобы проверить, как работает API, ещё до того как писать интерфейс.
Скачайте Postman с официального сайта postman.com (есть и веб-версия, но для запросов на localhost удобнее установленное приложение) и выполните четыре запроса.
GET — получить список
- Нажмите «New» → «HTTP» (или
+для новой вкладки). - Метод
GET, адресhttp://localhost:3001/todos. - Нажмите Send. Внизу появится ответ со статусом
200 OKи массивом задач.
Одну задачу можно получить по адресу http://localhost:3001/todos/1.
POST — создать задачу
- Метод
POST, адресhttp://localhost:3001/todos. - Вкладка Body → raw → в выпадающем списке справа выберите JSON.
- Тело запроса:
{
"title": "Задача из Postman",
"completed": false
}
- Send. Статус
201 Created, в ответе — новая задача сid, который выдал сервер. Загляните вdb.json— запись появилась в файле.
PATCH — изменить поле
Метод PATCH, адрес http://localhost:3001/todos/3, Body → raw → JSON:
{ "completed": true }
Сервер изменит только поле completed, остальные поля останутся. (PUT заменил бы задачу целиком — нужно было бы прислать все поля.)
DELETE — удалить
Метод DELETE, адрес http://localhost:3001/todos/3, тело не нужно. После запроса задача исчезнет из db.json.
Шаг 4. Знакомимся с axios
С fetch вы уже работали в уроке fetch, async/await и GET. axios делает то же самое, но удобнее. Сравним одинаковый POST-запрос:
// fetch
const response = await fetch('http://localhost:3001/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Новая задача', completed: false }),
});
if (!response.ok) throw new Error(`Ошибка ${response.status}`);
const todo = await response.json();
// axios
const { data: todo } = await axios.post('http://localhost:3001/todos', {
title: 'Новая задача',
completed: false,
});
fetch |
axios |
|
|---|---|---|
| Установка | Встроен в браузер | npm install axios |
| Превратить объект в JSON | Вручную JSON.stringify + заголовок |
Автоматически |
| Получить данные ответа | await response.json() |
Сразу в response.data |
| Ошибки 404, 500 | Не выбрасывает ошибку, нужно проверять response.ok |
Выбрасывает ошибку сама |
| Общие настройки (адрес, заголовки) | Писать обёртку самому | axios.create({ baseURL }) |
Оба инструмента рабочие, и знать нужно оба. В этом проекте используем axios.
Экземпляр axios с базовым адресом
Чтобы не повторять http://localhost:3001 в каждом запросе, создадим экземпляр axios с настройками. Адрес вынесем в переменную окружения — так его легко поменять, когда появится настоящий сервер.
Создайте в корне проекта файл .env:
VITE_API_URL=http://localhost:3001
В Vite в код попадают только переменные, которые начинаются с VITE_, а читаются они через import.meta.env. После изменения .env перезапустите npm run dev.
// src/api/client.js
import axios from 'axios';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 5000, // если сервер молчит 5 секунд — ошибка
});
Шаг 5. Слой API
Хорошая практика — не писать запросы прямо в компонентах, а собрать их в отдельный файл. Компоненты будут вызывать понятные функции вроде createTodo(title) и не думать об адресах и методах.
// src/api/todos.js
import { api } from './client';
export async function getTodos() {
const { data } = await api.get('/todos');
return data;
}
export async function createTodo(title) {
const { data } = await api.post('/todos', { title, completed: false });
return data; // сервер вернёт задачу вместе с новым id
}
export async function updateTodo(id, changes) {
const { data } = await api.patch(`/todos/${id}`, changes);
return data;
}
export async function deleteTodo(id) {
await api.delete(`/todos/${id}`);
}
api.get('/todos')— axios склеитbaseURLи путь:http://localhost:3001/todos.{ data }— деструктуризация: из ответа axios сразу берём тело.updateTodo(id, changes)— универсальная функция:changesможет быть{ completed: true }или{ title: 'Новый текст' }.
Шаг 6. Компоненты
Структура:
src/
├── api/
│ ├── client.js
│ └── todos.js
├── components/
│ ├── TodoForm.jsx
│ ├── TodoItem.jsx
│ └── TodoItem.module.css
├── App.jsx
├── App.module.css
└── main.jsx
TodoForm — добавление задачи
// src/components/TodoForm.jsx
import { useState } from 'react';
function TodoForm({ onAdd }) {
const [title, setTitle] = useState('');
const [isSending, setIsSending] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
const text = title.trim();
if (!text) return;
setIsSending(true);
try {
await onAdd(text);
setTitle(''); // очищаем поле только при успехе
} finally {
setIsSending(false);
}
}
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: 8 }}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Что нужно сделать?"
style={{ flex: 1, padding: 8 }}
/>
<button type="submit" disabled={isSending || !title.trim()}>
{isSending ? 'Добавляем...' : 'Добавить'}
</button>
</form>
);
}
export default TodoForm;
Форма ничего не знает о сервере: она просто вызывает onAdd(text) из пропсов и ждёт результат. Кнопка заблокирована, пока поле пустое или идёт отправка.
TodoItem — одна задача с редактированием
// src/components/TodoItem.jsx
import { useState } from 'react';
import styles from './TodoItem.module.css';
function TodoItem({ todo, onToggle, onDelete, onRename }) {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(todo.title);
function startEdit() {
setDraft(todo.title);
setIsEditing(true);
}
async function saveEdit() {
const text = draft.trim();
if (text && text !== todo.title) {
await onRename(todo.id, text);
}
setIsEditing(false);
}
function handleKeyDown(e) {
if (e.key === 'Enter') saveEdit();
if (e.key === 'Escape') setIsEditing(false);
}
return (
<li className={styles.item}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id, !todo.completed)}
/>
{isEditing ? (
<input
className={styles.edit}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={saveEdit}
autoFocus
/>
) : (
<span
className={`${styles.title} ${todo.completed ? styles.done : ''}`}
onDoubleClick={startEdit}
>
{todo.title}
</span>
)}
<button onClick={startEdit} disabled={isEditing}>✏️</button>
<button onClick={() => onDelete(todo.id)}>🗑️</button>
</li>
);
}
export default TodoItem;
/* src/components/TodoItem.module.css */
.item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 0;
border-bottom: 1px solid #e5e7eb;
}
.title {
flex: 1;
cursor: pointer;
}
.done {
text-decoration: line-through;
color: #9ca3af;
}
.edit {
flex: 1;
padding: 4px 6px;
}
Разбор:
isEditing— локальное состояние задачи: показываем текст или поле ввода. Другим задачам иAppэто знать не нужно, поэтому состояние живёт здесь.draft— черновик текста. Пока пользователь печатает, мы не дёргаем сервер; запрос уходит только при сохранении.- Сохранение — по Enter или при уходе с поля (
onBlur); Escape — отмена. autoFocusставит курсор в поле редактирования сразу при появлении.- Двойной клик по тексту тоже включает редактирование.
App — состояние и запросы
// src/App.jsx
import { useEffect, useState } from 'react';
import { getTodos, createTodo, updateTodo, deleteTodo } from './api/todos';
import TodoForm from './components/TodoForm';
import TodoItem from './components/TodoItem';
import styles from './App.module.css';
const FILTERS = {
all: () => true,
active: (todo) => !todo.completed,
done: (todo) => todo.completed,
};
function App() {
const [todos, setTodos] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [filter, setFilter] = useState('all');
// 1. Загрузка списка при открытии
useEffect(() => {
getTodos()
.then((data) => setTodos(data))
.catch(() => setError('Не удалось загрузить задачи. Запущен ли json-server?'))
.finally(() => setIsLoading(false));
}, []);
// 2. Добавление
async function handleAdd(title) {
try {
const newTodo = await createTodo(title);
setTodos((prev) => [...prev, newTodo]);
} catch {
setError('Не удалось добавить задачу');
}
}
// 3. Отметка выполнения
async function handleToggle(id, completed) {
try {
const updated = await updateTodo(id, { completed });
setTodos((prev) => prev.map((t) => (t.id === id ? updated : t)));
} catch {
setError('Не удалось обновить задачу');
}
}
// 4. Переименование
async function handleRename(id, title) {
try {
const updated = await updateTodo(id, { title });
setTodos((prev) => prev.map((t) => (t.id === id ? updated : t)));
} catch {
setError('Не удалось переименовать задачу');
}
}
// 5. Удаление
async function handleDelete(id) {
if (!confirm('Удалить задачу?')) return;
try {
await deleteTodo(id);
setTodos((prev) => prev.filter((t) => t.id !== id));
} catch {
setError('Не удалось удалить задачу');
}
}
const visibleTodos = todos.filter(FILTERS[filter]);
const activeCount = todos.filter((t) => !t.completed).length;
return (
<div className={styles.app}>
<h1>Мои задачи</h1>
<TodoForm onAdd={handleAdd} />
{error && (
<p className={styles.error}>
{error} <button onClick={() => setError(null)}>✕</button>
</p>
)}
<div className={styles.filters}>
<button className={filter === 'all' ? styles.active : ''} onClick={() => setFilter('all')}>Все</button>
<button className={filter === 'active' ? styles.active : ''} onClick={() => setFilter('active')}>Активные</button>
<button className={filter === 'done' ? styles.active : ''} onClick={() => setFilter('done')}>Выполненные</button>
</div>
{isLoading ? (
<p>Загрузка...</p>
) : visibleTodos.length === 0 ? (
<p>Здесь пока пусто</p>
) : (
<ul className={styles.list}>
{visibleTodos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
onRename={handleRename}
/>
))}
</ul>
)}
<p className={styles.counter}>Осталось задач: {activeCount}</p>
</div>
);
}
export default App;
/* src/App.module.css */
.app { max-width: 520px; margin: 40px auto; padding: 0 16px; font-family: system-ui, sans-serif; }
.list { list-style: none; padding: 0; }
.filters { display: flex; gap: 8px; margin: 16px 0; }
.active { background: #2563eb; color: white; }
.error { color: #dc2626; }
.counter { color: #6b7280; }
Разбор App
- Состояние.
todos— данные с сервера;isLoadingиerror— для интерфейса загрузки и ошибок;filter— выбранная вкладка. - Загрузка.
useEffectс пустым массивом запускаетgetTodos()один раз. Здесь показан вариант с.then/.catch/.finally— он тоже рабочий и короче для простого случая. - Сначала сервер, потом экран. В каждом обработчике мы ждём ответа сервера и только потом обновляем
todos. Если сервер вернул ошибку — состояние не меняется, и пользователь видит сообщение. Экран всегда совпадает с тем, что лежит в базе. - Ответ сервера — источник правды. После
PATCHмы заменяем задачу тем объектом, который вернул сервер (updated), а при создании — берёмnewTodoсidот сервера. - Неизменяемые обновления.
[...prev, newTodo],map,filter— как в уроке проuseState. - Фильтр вычисляется, а не хранится:
visibleTodosиactiveCountсчитаются изtodosпри каждом рендере. - Вложенный тернарник выбирает одно из трёх состояний: загрузка, пусто, список.
Шаг 7. Запуск и проверка
# терминал 1
npm run server
# терминал 2
npm run dev
Проверьте:
- Открылся список из
db.json. - Добавьте задачу — она появилась в списке и в файле
db.json. - Отметьте, переименуйте (двойной клик), удалите — обновите страницу, изменения сохранились.
- Откройте DevTools → вкладка Network → фильтр Fetch/XHR. Каждое действие — отдельный запрос с методом, статусом и телом (повторите урок JSON и DevTools).
- Остановите json-server и обновите страницу — должно появиться понятное сообщение об ошибке, а не белый экран.
Типичные ошибки
| Симптом | Причина | Решение |
|---|---|---|
Network Error / ERR_CONNECTION_REFUSED |
json-server не запущен или другой порт | Запустить npm run server, сверить порт в .env |
import.meta.env.VITE_API_URL равен undefined |
Нет префикса VITE_ или не перезапущен dev-сервер |
Проверить имя, перезапустить npm run dev |
404 Not Found |
Опечатка в пути (/todo вместо /todos) |
Сверить с ключом в db.json |
| Страница перезагружается при добавлении | Нет e.preventDefault() |
Добавить в handleSubmit |
| Изменения пропадают после перезагрузки | Состояние меняется, а запрос не отправляется | Сначала вызывать функцию API |
db.json лежит в src и Vite перезагружает страницу |
Vite следит за файлами в проекте | Хранить db.json в корне проекта |
Практика
- Postman. Создайте коллекцию «ToDo API» с запросами GET (все), GET (одна задача), POST, PATCH, DELETE. Ожидаемый результат: все запросы отрабатывают, изменения видны в
db.json. - Базовый проект. Повторите ToDoList по шагам этого урока. Ожидаемый результат: все 5 действий работают и сохраняются после перезагрузки.
- Кнопка «Удалить выполненные». Удаляет все задачи с
completed: true. Подсказка:Promise.allпо массиву запросовdeleteTodo(вспомните урок Event Loop и Promise). - Тосты. Подключите react-toastify из урока Формы и react-toastify: вместо блока
errorпоказывайтеtoast.error, а при добавлении и удалении —toast.success. - Дата и приоритет. Добавьте в задачу поля
createdAt(дата создания,new Date().toISOString()) иpriority(low/medium/high, выбор черезselectв форме). Выводите дату в форматеtoLocaleDateString('ru-RU')и цветную метку приоритета. Добавьте сортировку по приоритету.
Итоги
- json-server за минуту превращает
db.jsonв REST API:GET,POST,PATCH,DELETEпо адресу/todos. - Postman позволяет проверить API без кода: метод, адрес, тело в Body → raw → JSON, Send.
- axios сам превращает данные в JSON, кладёт ответ в
response.dataи выбрасывает ошибку на статусах 4xx/5xx. axios.create({ baseURL })+ адрес в.env(VITE_API_URL) избавляют от повторов.- Запросы выносят в слой
api/, компоненты вызывают понятные функции. - Сначала дожидаемся ответа сервера, потом обновляем состояние неизменяемыми методами, а ошибки показываем пользователю.