Progressive enhancement — ідея, що сторінка має працювати без JavaScript і ставати кращою з ним — колись була простою. Рендери HTML на сервері, трохи JS для взаємодій. Якщо JS не завантажився — сторінка все одно працює.
React Server Components змінюють рівняння. Одні речі стали простішими, ніж будь-коли. Інші вимагають усвідомленого вибору. Ось що я виніс із shipped RSC-застосунків на Next.js на реальних юзерах.
Що стало кращим
Форми
Це найбільший виграш. Server Actions перетворюють форми на progressively enhanced компоненти за замовчуванням.
async function subscribe(formData: FormData) {
'use server'
const email = formData.get('email') as string
await db.subscribers.create({ data: { email } })
}
export default function NewsletterForm() {
return (
<form action={subscribe}>
<input type="email" name="email" required />
<button type="submit">Subscribe</button>
</form>
)
}
Ця форма працює з вимкненим JavaScript. Браузер відправляє стандартний POST-запит, server action виконується, сторінка перезавантажується. З увімкненим JavaScript React обробляє все на клієнті — без перезавантаження, миттєвий фідбек.
До Server Actions форма потребувала або окремого API-роуту (зайвий файл, зайвий код), або клієнтського fetch (нема JS — нема форми). Тепер progressive enhancement вбудований.
Контентні сторінки
Server Components рендеряться в HTML на сервері. Пости блогу, документація, маркетингові сторінки — приходять як повноцінний HTML. Гідрація не потрібна для статичного контенту. Браузер рендерить їх миттєво, і вони відмінно працюють без JavaScript.
Це те, що HTML завжди і робив, але тепер це дефолт у React, а не щось, за що треба боротися.
Навігація
Next.js App Router prefetch-ить посилання і обробляє клієнтську навігацію автоматично. Але під капотом посилання — це звичайні теги <a>. Якщо JavaScript не працює, клік по посиланню робить повне page navigation — і це нормально.
Що потребує думання
Інтерактивні компоненти в серверно-рендерених сторінках
Сторінка може бути Server Component із кишенями клієнтської інтерактивності. Типовий патерн:
// Server Component — рендериться в HTML, без JS
export default async function ProductPage({ params }) {
const product = await getProduct(params.id)
return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Client Component — потребує JS */}
<AddToCartButton productId={product.id} />
</main>
)
}
Без JavaScript юзер бачить інформацію про продукт (добре), але кнопка «Додати до кошика» не працює (погано). Питання: що має відбуватись, коли JS недоступний?
Варіант 1: Fallback-форма. Обгорни кнопку у <form> із Server Action. Немає JS? Форма сабмітиться. JS є? Клієнтський обробник бере управління. Це найчистіший progressive enhancement, але він працює тільки для дій, які можна виразити як form submission.
Варіант 2: Прийняти обмеження. Для складних взаємодій (drag-and-drop, real-time оновлення, canvas-based UI) HTML-фолбеку не існує. Задокументуй вимогу до JS і переконайся, що сторінка деградує коректно — показує контент, ховає нефункціональні елементи.
Варіант 3: Повідомлення через <noscript>. Показуй повідомлення при вимкненому JS: «Увімкніть JavaScript для повного функціоналу.» Чесно, але виглядає як відмова від відповідальності для простих взаємодій.
Я використовую Варіант 1 для будь-якої дії, яку можна виразити як форму (додати до кошика, підписатись, контакт, toggle налаштувань). Варіант 2 — для справді складних UI.
Стани завантаження
Server Components стримують — shell рендериться миттєво, повільні дані приходять пізніше. З JavaScript юзер бачить Suspense-межі і стани завантаження. Без JavaScript — нічого, поки вся сторінка не завантажиться.
Це справжнє погіршення. Юзер на повільному з'єднанні без JS бачить порожню сторінку довше, ніж на традиційній серверно-рендереній сторінці (яка чекає всі дані перед відправкою відповіді).
Митигація: тримай Suspense-межі вузькими. Не обгортай всю сторінку в один Suspense — обгортай окремі повільні секції. Частини, які не await-ять дані, рендеряться одразу як HTML незалежно від підтримки JS.
Клієнтський стан
Будь-який стан в useState, useReducer або бібліотеці управління станом потребує JavaScript. Кошики, фільтри, табки, акордеони — якщо вони існують лише в клієнтському стані, вони зникають без JS.
Патерн, що працює: кодуй стан в URL.
// Замість useState для фільтрів
// URL: /products?category=shoes&sort=price
export default async function Products({ searchParams }) {
const products = await getProducts({
category: searchParams.category,
sort: searchParams.sort,
})
return (
<form method="get">
<select name="category">
<option value="shoes">Shoes</option>
<option value="shirts">Shirts</option>
</select>
<button type="submit">Filter</button>
</form>
)
}
URL-based стан працює без JavaScript (форма сабмітиться, сторінка перезавантажується з новими параметрами). З JavaScript можна перехоплювати форму і оновлювати клієнтський стан через router.push.
Що не працює без JavaScript
Будь чесним щодо цього — не вдавай, що можна progressively enhanced все:
- Real-time оновлення (WebSocket, polling). Нема JS — нема real-time.
- Складні анімації (Framer Motion, GSAP). Сторінка має нормально виглядати без них — це enhancement, а не контент.
- Клієнтський пошук/фільтрація, яка не використовує URL-параметри. Потребує JS для фільтрації DOM-елементів.
- Сторонні віджети (чат, аналітичні дашборди). Це JS-застосунки.
- Drag and drop, canvas, WebGL. HTML-фолбеку не існує.
Практичний тест
Для кожної сторінки, яку я шипаю, я проводжу такий тест: вимикаю JavaScript у браузері і завантажую сторінку.
- Чи може юзер читати контент? Якщо ні — щось не так: контент завжди має бути HTML.
- Чи може юзер переходити на інші сторінки? Посилання мають працювати. Якщо навігація залежить від JS — це баг.
- Чи може юзер виконати основну дію? Для контактної сторінки: чи може він відправити форму? Для сторінки продукту: чи може він хоча б побачити деталі продукту?
- Чи деградують інтерактивні елементи коректно? Табки мають показувати весь контент (а не ховати його за JS-only-табками). Акордеони мають бути розкриті за замовчуванням.
Цей тест займає 2 хвилини на сторінку. Він виловлює реальні проблеми — не гіпотетичні сценарії «а що як JS зламається», а справжні UX-проблеми для юзерів на повільних з'єднаннях, де JS вичерпує таймаут.
Чесна позиція
Progressive enhancement — не про підтримку юзерів, які навмисно вимикають JavaScript. Це про стійкість для юзерів на поганих з'єднаннях, старих пристроях або в ситуаціях, де JS не завантажується (помилка CDN, конфлікт з блокувальником реклами, корпоративний проксі).
В епоху RSC дефолт кращий, ніж будь-коли — для контенту і форм. Робота полягає в тому, щоб інтерактивні компоненти деградували коректно — і щоб бути чесним щодо тих, які не можуть.
Будуєш із RSC і хочеш, щоб твої сторінки працювали для всіх? Пиши — я шипаю progressively enhanced застосунки, де контент працює навіть коли JavaScript не завантажується.