Кейс — сайт на React к этому API
работает в curl, не работает в браузере
Что это
Третий кейс продолжает второй: API из статьи 14.2 уже работает, теперь к нему нужен сайт. Задача выглядит простой — показать список, — и именно на ней вылезает ошибка, которой нет ни в одном руководстве по React, потому что она не про React.
Шаг 1. Запрос
React 19 + TypeScript, сборка Vite.
Один экран: список городов с температурой.
Данные: GET http://localhost:8102/cities,
ответ [{name: string, temp: number | null}].
Нужны все четыре состояния: загрузка, ошибка,
пустой список, данные. Тип ответа опиши явно,
any и as не использовать.
Стили простые, без библиотек компонентов.Требование про четыре состояния — из статьи 10.5, и оно здесь не для галочки: именно оно спасёт на следующем шаге.
Шаг 2. Что пришло
type City = { name: string; temp: number | null }
export default function App() {
const [cities, setCities] = useState<City[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('http://localhost:8102/cities')
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
})
.then(setCities)
.catch((e) => setError(String(e)))
.finally(() => setLoading(false))
}, [])
if (loading) return <p className="state">Загружаем…</p>
if (error) return <p className="state err">Не удалось загрузить: {error}</p>
if (cities.length === 0) return <p className="state">Городов пока нет</p>
return (
<main>
<h1>Погода</h1>
<ul>
{cities.map((c) => (
<li key={c.name}>
<span>{c.name}</span>
<b>{c.temp === null ? 'нет данных' : `${c.temp}°`}</b>
</li>
))}
</ul>
</main>
)
}Код правильный. Зависимости у эффекта пустые, key на месте, четыре состояния
есть, any нет. Собирается:
✓ built in 1.82sШаг 3. Баг, которого нет в коде
Открываем в браузере:
Не удалось загрузить: TypeError: Failed to fetchПри этом API жив и прекрасно отвечает:
$ curl -s -o /dev/null -w "%{http_code}" localhost:8102/cities
200Работает в curl, не работает в браузере. Причина — в консоли
разработчика (статья 8.4), и только там:
Access to fetch at 'http://localhost:8102/cities' from origin
'http://localhost:5190' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.Это CORS — правило браузера, по которому страница с одного адреса не
может читать ответы с другого, если тот явно не разрешил. У curl такого
правила нет: оно существует только в браузере и только ради защиты
пользователя.
Два вывода, которые стоят этой статьи.
Первый: ошибка на экране не называет причину. Браузер намеренно не отдаёт
подробности коду страницы — fetch получает только TypeError: Failed to fetch. Искать надо в консоли, а не в коде React. Запросы вида «почини
TypeError: Failed to fetch» бесполезны: в показанном коде ошибки нет.
Второй: чинить надо на сервере. CORS настраивается там, откуда берут данные:
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5190"], # адрес сайта, не "*"
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["Content-Type"],
)После перезапуска API заголовок появляется:
$ curl -D- -H "Origin: http://localhost:5190" localhost:8102/cities
access-control-allow-origin: http://localhost:5190И страница оживает — ошибок в консоли нет:

Шаг 4. Что проверять глазами
Собранный сайт — не то же самое, что сайт при разработке (глава 12). Минимум проверок перед тем, как считать кейс закрытым:
- Четыре состояния. Выключить API — должна появиться строка про ошибку, а не пустой экран. Это и была проверка на шаге 3, просто вынужденная.
- Узкое окно. Сузить до ширины телефона.
- Собранная версия.
vite buildи открыть результат, а не только режим разработки. - Адрес API не вшит намертво. На сервере он другой; значит, переменная окружения — и помнить, что она попадёт в артефакт открытым текстом (статья 12.1).
Шпаргалка
«Работает в curl, не работает в браузере» — почти всегда CORS.
Настоящая причина видна только в консоли браузера, а не на экране.
TypeError: Failed to fetch— это не ошибка в коде React.CORS чинится на сервере, который отдаёт данные, а не на сайте.
allow_origins=["*"]— не решение, а отказ от решения.Четыре состояния экрана окупились в первый же день.
Проверять надо собранную версию и узкое окно, а не только режим разработки.