Доки по разработке
This project is maintained by teniryte
Здесь собраны ключевые идеи React 18: concurrent rendering, Suspense, стратегии кеширования данных, transitions, оптимистичный UI, useDeferredValue и работа с тяжёлыми списками.
Concurrent rendering — это способ работы React, при котором рендеринг можно прерывать, откладывать и объединять:
Вы напрямую не управляетесь планировщиком — React делает это сам. Вы лишь помечаете обновления как срочные или нет:
setState внутри обработчиков событий — срочные;startTransition — не срочные;useDeferredValue позволяет отложить применение “тяжёлого” значения.Suspense позволяет показывать запасной UI, пока часть дерева “занята” — загружает данные, код или что‑то ещё. Ключевая идея: компонент может “бросить” Promise, и пока он не выполнится, React будет использовать fallback.
// Имитация асинхронной загрузки
function fetchUsers(): Promise<Array<{ id: number; name: string }>> {
return new Promise(resolve => {
setTimeout(() => {
resolve([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
}, 1500);
});
}
const usersPromise = fetchUsers();
function UsersList() {
const users = use(usersPromise); // use "останавливает" компонент, пока промис не выполнится
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
export function UsersPage() {
return (
<Suspense fallback={<div>Загружаем пользователей...</div>}>
<UsersList />
</Suspense>
);
}
Пока fetchUsers не вернул результат, React показывает fallback. Когда промис завершится — дерево “размораживается”, и UsersList отрисовывается с данными.
Чтобы не только ждать данные, но и корректно обрабатывать ошибки, добавляют Error Boundary.
function ErrorFallback({ error }: { error: Error }) {
return (
<div role="alert">
<p>Ошибка загрузки:</p>
<pre>{error.message}</pre>
</div>
);
}
async function fetchUser(id: number) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Failed to load user');
return res.json() as Promise<{ id: number; name: string }>;
}
const userPromise = fetchUser(1);
function UserInfo() {
const user = use(userPromise);
return <div>User: {user.name}</div>;
}
export function PageWithErrorBoundary() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<div>Загрузка...</div>}>
<UserInfo />
</Suspense>
</ErrorBoundary>
);
}
Чтобы не загружать одни и те же данные много раз, используют простые кеши:
Идея: кешировать промис загрузки, а не результат. Тогда:
type User = { id: number; name: string };
const userPromiseCache = new Map<number, Promise<User>>();
function fetchUserImpl(id: number): Promise<User> {
return fetch(`/api/users/${id}`).then(res => {
if (!res.ok) throw new Error('Failed');
return res.json() as Promise<User>;
});
}
function getUserPromise(id: number): Promise<User> {
let promise = userPromiseCache.get(id);
if (!promise) {
promise = fetchUserImpl(id);
userPromiseCache.set(id, promise);
}
return promise;
}
function UserItem({ id }: { id: number }) {
const user = use(getUserPromise(id));
return <div>{user.name}</div>;
}
export function UsersListCached() {
return (
<Suspense fallback={<div>Загрузка...</div>}>
<UserItem id={1} />
<UserItem id={2} />
<UserItem id={1} /> {/* повторно использует кеш */}
</Suspense>
);
}
Иногда нужно, чтобы одна часть UI загружалась независимо от другой. Например:
function UsersMasterDetail() {
const [selectedId, setSelectedId] = useState<number | null>(null);
const users = use(fetchUsers()); // список пользователей
return (
<div>
{users.map(user => (
<button key={user.id} onClick={() => setSelectedId(user.id)}>
{user.name}
</button>
))}
<Suspense fallback={<div>Загрузка деталей...</div>}>
{selectedId && <UserItem id={selectedId} />}
</Suspense>
</div>
);
}
export function NestedSuspensePage() {
return (
<Suspense fallback={<div>Загрузка списка...</div>}>
<UsersMasterDetail />
</Suspense>
);
}
Здесь внешний Suspense отвечает за загрузку списка, внутренний — за детали выбранного пользователя.
useTransition и startTransition)Transitions позволяют помечать обновления как “не срочные”. Это удобно, когда:
Мы не хотим задерживать ввод ради списка — поэтому обновление списка помечается как transition.
export function UsersWithTransition({ users }: { users: User[] }) {
const [filter, setFilter] = useState('');
const [visibleUsers, setVisibleUsers] = useState<User[]>(users);
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setFilter(value); // срочное обновление — текст ввода
startTransition(() => {
// менее срочное — фильтрация большого списка
setVisibleUsers(
users.filter(user =>
user.name.toLowerCase().includes(value.toLowerCase()),
),
);
});
}
return (
<div>
<input value={filter} onChange={handleChange} />
{isPending && <div>Обновляем список...</div>}
<ul style=>
{visibleUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Пока transition не завершён, isPending будет true, и вы можете визуально подсветить “промежуточное” состояние.
useOptimistic)Оптимистичный UI показывает результат действия ещё до того, как сервер его подтвердит. Это делает интерфейс отзывчивым, особенно при медленном соединении.
export function OptimisticNumbers() {
const [numbers, setNumbers] = useState<number[]>([]);
const [isPending, startTransition] = useTransition();
const [optimisticNumbers, addOptimisticNumber] = useOptimistic<
number[],
number
>(numbers, (current, newNumber) => [...current, newNumber]);
async function handleAddNumber(newNumber: number) {
// Сразу показываем число в списке
addOptimisticNumber(newNumber);
startTransition(async () => {
// имитация запроса к серверу
await new Promise(resolve => setTimeout(resolve, 1000));
setNumbers(prev => [...prev, newNumber]);
});
}
return (
<div>
<ul>
{optimisticNumbers.map(n => (
<li key={n}>{n}</li>
))}
</ul>
<form
action={async (data: FormData) => {
const value = Number(data.get('number'));
await handleAddNumber(value);
}}
>
<input type="number" name="number" />
<button type="submit" disabled={isPending}>
Добавить
</button>
</form>
</div>
);
}
export function MultiStepAction() {
const [message, setMessage] = useOptimistic('Submit');
const [isPending, startTransition] = useTransition();
const handleClick = () => {
startTransition(async () => {
setMessage('Создаём заказ...');
await new Promise(r => setTimeout(r, 800));
setMessage('Отправляем данные...');
await new Promise(r => setTimeout(r, 800));
setMessage('Подтверждаем заказ...');
await new Promise(r => setTimeout(r, 800));
setMessage('Submit'); // вернуться к исходному состоянию
});
};
return (
<button onClick={handleClick} disabled={isPending}>
{message}
</button>
);
}
Suspense можно использовать не только для данных, но и для загрузки ресурсов, например изображений. Идея:
img.onload/img.onerror;use для этого промиса;<Suspense> и, при необходимости, в Error Boundary.type LoadedImage = { url: string; width: number; height: number };
function preloadImage(src: string): Promise<LoadedImage> {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = src;
img.onload = () =>
resolve({ url: src, width: img.width, height: img.height });
img.onerror = () => reject(new Error('Failed to load image'));
});
}
function AsyncImage({ src }: { src: string }) {
const { url } = use(preloadImage(src));
return <img src={url} alt="" />;
}
export function SuspenseImageExample({ src }: { src: string }) {
return (
<ErrorBoundary fallback={<div>Не удалось загрузить изображение</div>}>
<Suspense fallback={<div>Загрузка изображения...</div>}>
<AsyncImage src={src} />
</Suspense>
</ErrorBoundary>
);
}
useDeferredValue: откладываем тяжёлый рендерuseDeferredValue берёт какое‑то значение и возвращает “отложенную” версию: React может обновлять её чуть позже, когда у браузера будет время. Это особенно полезно при фильтрации/поиске по длинному списку.
const WORDS = 'Хук useDeferredValue в React — это инструмент оптимизации производительности'
.toLowerCase()
.split(' ');
export function DeferredSearch() {
const [search, setSearch] = useState('');
const deferredSearch = useDeferredValue(search);
const isPending = search !== deferredSearch;
const filtered = useMemo(
() => WORDS.filter(word => word.includes(deferredSearch)),
[deferredSearch],
);
return (
<div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Введите часть слова..."
/>
<div style=>
{filtered.map((word, index) => (
<div key={word + index}>{word}</div>
))}
</div>
</div>
);
}
Пользователь видит мгновенный отклик ввода, а тяжёлая фильтрация может немного “отставать”, не блокируя интерфейс.
В реальном приложении вместо небольшого массива может быть гигантский список. Тогда для оптимизации используют:
useMemo);useDeferredValue);const BIG_LIST = Array.from({ length: 50_000 }, (_, i) => `Элемент ${i + 1}`);
function HeavyList({ query }: { query: string }) {
const filtered = useMemo(() => {
const start = performance.now();
// имитируем тяжёлую фильтрацию
while (performance.now() - start < 10) {}
return BIG_LIST.filter(item =>
item.toLowerCase().includes(query.toLowerCase()),
);
}, [query]);
return (
<ul>
{filtered.map(item => (
<li key={item}>{item}</li>
))}
</ul>
);
}
export function DeferredHeavyList() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Поиск..."
/>
<HeavyList query={deferredQuery} />
</div>
);
}
use с несколькими промисамиХук use можно применять и к нескольким независимым промисам, которые загружаются параллельно.
function waitNumber(n: number, delay: number) {
return new Promise<number>(resolve => {
setTimeout(() => resolve(n), delay);
});
}
export function ParallelNumbers() {
const a = use(waitNumber(1, 1000));
const b = use(waitNumber(2, 1200));
const c = use(waitNumber(3, 800));
return (
<div>
Числа: {[a, b, c].join(', ')}
</div>
);
}
В сочетании с Suspense это позволяет строить интерфейсы, которые постепенно “дорисовываются” по мере готовности данных, не блокируя весь экран.