Pytania rekrutacyjne Remix
40 pytań — 20 odpowiedzi dostępnych od razu
Podstawy i Architektura
1. Czym jest Remix i jakie problemy rozwiązuje w porównaniu do innych frameworków React?
Odpowiedź w 30 sekund: Remix to full-stack framework React oparty na Web Standards, który przenosi renderowanie na serwer i wykorzystuje natywne możliwości platformy web. Rozwiązuje problemy związane z wydajnością ładowania stron, obsługą formularzy, zarządzaniem stanem serwer-klient oraz progresywnym wzbogacaniem funkcjonalności bez konieczności budowania skomplikowanych rozwiązań po stronie klienta.
Odpowiedź w 2 minuty: Remix to framework React stworzony przez twórców React Router, który skupia się na wykorzystaniu natywnych możliwości platformy web zamiast budowania własnych abstrakcji. Główne problemy, które rozwiązuje to: nadmierne poleganie na JavaScript po stronie klienta, skomplikowane zarządzanie stanem między serwerem a klientem, problemy z wydajnością initial load oraz nieintuicyjna obsługa formularzy i mutacji danych.
W przeciwieństwie do tradycyjnych SPA, Remix domyślnie renderuje wszystko na serwerze i wysyła gotowy HTML do przeglądarki, a następnie wzbogaca go o JavaScript (progressive enhancement). Wykorzystuje standardowe Web API jak Request, Response, Headers i FormData, co oznacza że developer nie musi uczyć się specyficznych abstrakcji frameworka - wystarczy znajomość standardów web.
Remix rozwiązuje też problem "data waterfalls" poprzez równoległe ładowanie danych na poziomie zagnieżdżonych tras. Framework automatycznie zarządza rewalidacją danych po mutacjach, eliminując potrzebę ręcznego synchronizowania stanu cache. Dodatkowo, formularze działają nawet bez JavaScript, co zapewnia lepszą dostępność i odporność aplikacji.
Kluczową różnicą jest filozofia: zamiast budować skomplikowane state management solutions po stronie klienta, Remix wykorzystuje serwer jako źródło prawdy i minimalizuje stan klienta do niezbędnego minimum.
Przykład kodu:
// Tradycyjny loader w Remix - zwykła funkcja serwerowa
export async function loader({ request }: LoaderFunctionArgs) {
// Wykorzystanie standardowego Web API
const url = new URL(request.url);
const searchTerm = url.searchParams.get("q");
// Pobieranie danych z bazy
const products = await db.product.findMany({
where: { name: { contains: searchTerm } }
});
// Zwracanie standardowego Response
return json({ products });
}
// Action obsługujący formularz - również funkcja serwerowa
export async function action({ request }: ActionFunctionArgs) {
// FormData z natywnego Web API
const formData = await request.formData();
const name = formData.get("name");
// Walidacja i zapisanie danych
const product = await db.product.create({
data: { name: String(name) }
});
// Przekierowanie po udanej operacji
return redirect(`/products/${product.id}`);
}
// Komponent - działa bez JavaScript dzięki progressive enhancement
export default function ProductsRoute() {
const { products } = useLoaderData<typeof loader>();
return (
<div>
<Form method="post">
<input name="name" required />
<button type="submit">Dodaj produkt</button>
</Form>
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
);
}
Diagram:
flowchart TD
A[Żądanie użytkownika] --> B[Remix Server]
B --> C{Typ żądania}
C -->|GET| D[Wywołanie loaderów]
C -->|POST/PUT/DELETE| E[Wywołanie action]
D --> F[Równoległe pobieranie danych]
E --> G[Mutacja danych]
F --> H[Renderowanie HTML na serwerze]
G --> I[Rewalidacja danych]
I --> H
H --> J[Wysłanie HTML do klienta]
J --> K[Hydratacja JavaScript]
K --> L[Progressive Enhancement]
Materiały:
2. Jakie są kluczowe różnice między Remix a Next.js?
Odpowiedź w 30 sekund: Główne różnice to: Remix domyślnie renderuje wszystko na serwerze i wykorzystuje progressive enhancement, podczas gdy Next.js oferuje wybór między SSR, SSG i CSR. Remix opiera się na Web Standards (FormData, Request/Response), Next.js ma własne abstrakcje. Remix nie ma build-time rendering (wszystko runtime), a Next.js promuje Static Generation jako domyślną metodę.
Odpowiedź w 2 minuty: Remix i Next.js to dwa najpopularniejsze full-stack frameworki React, ale różnią się fundamentalnie w filozofii i podejściu. Next.js oferuje elastyczność wyboru między różnymi strategiami renderowania (SSG, SSR, ISR, CSR) i promuje statyczną generację jako domyślną opcję dla lepszej wydajności. Remix natomiast renderuje wszystko w runtime na serwerze, nie ma koncepcji build-time generation.
W kwestii obsługi danych i formularzy różnice są znaczące. Next.js wykorzystuje własne abstrakcje jak API Routes, Server Components i Server Actions. Remix opiera się na standardowych Web API - używa natywnych Request/Response objects, FormData API i standardowych form submissions. To oznacza, że w Remix formularze działają bez JavaScript, podczas gdy Next.js tradycyjnie wymaga JavaScript do obsługi interakcji.
Routing również się różni: oba frameworki używają file-based routing, ale Remix ma bardziej zaawansowane nested routing z automatic data loading i error boundaries na każdym poziomie zagnieżdżenia. Next.js App Router wprowadził podobne koncepcje, ale Remix miał je od początku jako core feature.
Kolejna kluczowa różnica to podejście do optimistic UI i rewalidacji danych. Remix ma wbudowany system automatycznej rewalidacji po każdej mutacji, podczas gdy w Next.js trzeba to często implementować ręcznie lub używać dodatkowych bibliotek. Remix również nie wymaga osobnego API layer - loaders i actions są naturalną warstwą API.
Przykład kodu:
// REMIX - formularz z wykorzystaniem Web Standards
export async function action({ request }: ActionFunctionArgs) {
// Natywne FormData API
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
await deleteProduct(formData.get("id"));
} else {
await createProduct({
name: formData.get("name"),
price: Number(formData.get("price"))
});
}
// Automatyczna rewalidacja po przekierowaniu
return redirect("/products");
}
export default function ProductForm() {
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
// Działa bez JavaScript!
<Form method="post">
<input name="name" required />
<input name="price" type="number" required />
<button name="intent" value="create" disabled={isSubmitting}>
{isSubmitting ? "Zapisywanie..." : "Zapisz"}
</button>
</Form>
);
}
// NEXT.JS - podobna funkcjonalność z Server Actions
"use server"
export async function createProduct(formData: FormData) {
// Własna abstrakcja Next.js
const name = formData.get("name");
const price = formData.get("price");
await db.product.create({
data: { name, price: Number(price) }
});
// Manualna rewalidacja cache
revalidatePath("/products");
redirect("/products");
}
// Komponent wymaga "use client" dla interakcji
"use client"
export default function ProductForm() {
const [pending, setPending] = useState(false);
return (
<form action={createProduct} onSubmit={() => setPending(true)}>
<input name="name" required />
<input name="price" type="number" required />
<button disabled={pending}>
{pending ? "Zapisywanie..." : "Zapisz"}
</button>
</form>
);
}
// NESTED ROUTING - Remix
// app/routes/dashboard.tsx (parent route)
export async function loader() {
return json({ user: await getUser() });
}
export default function Dashboard() {
const { user } = useLoaderData<typeof loader>();
return (
<div>
<h1>Witaj {user.name}</h1>
{/* Outlet renderuje zagnieżdżone trasy */}
<Outlet />
</div>
);
}
// app/routes/dashboard.products.tsx (child route)
export async function loader() {
// Ładowane równolegle z parent loader!
return json({ products: await getProducts() });
}
Diagram:
graph TB
subgraph "Remix"
A1[Żądanie] --> B1[Runtime SSR]
B1 --> C1[Równoległe loadery]
C1 --> D1[HTML + Progressive Enhancement]
D1 --> E1[Natywne Web API]
end
subgraph "Next.js"
A2[Żądanie] --> B2{Strategia renderowania}
B2 -->|SSG| C2[Build-time HTML]
B2 -->|SSR| D2[Runtime SSR]
B2 -->|ISR| E2[Regeneracja w tle]
C2 --> F2[Własne abstrakcje API]
D2 --> F2
E2 --> F2
end
Materiały:
3. Jak Remix wykorzystuje standardy Web API (Request, Response, FormData)?
Odpowiedź w 30 sekund: Remix bazuje na Web Fetch API - loadery i actions przyjmują standardowe Request objects i zwracają Response objects. FormData API jest używany do obsługi formularzy zamiast kontrolowanych komponentów React. To podejście eliminuje framework-specific abstrakcje, sprawia że kod jest bardziej portable i działa zgodnie z natywnym zachowaniem przeglądarki.
Odpowiedź w 2 minuty: Remix przyjmuje filozofię "use the platform", co oznacza maksymalne wykorzystanie natywnych Web Standards zamiast tworzenia własnych abstrakcji. W centrum tego podejścia znajdują się trzy kluczowe API: Request, Response i FormData.
Request object jest przekazywany do każdego loadera i action - to standardowy obiekt Fetch API zawierający wszystkie informacje o żądaniu HTTP (metoda, nagłówki, URL, body). Developer może używać wszystkich standardowych metod jak request.headers.get(), request.formData(), czy new URL(request.url). To oznacza, że umiejętności nabyte w Remix są bezpośrednio transferowalne do innych kontekstów webowych, nie tylko React.
Response object jest używany do zwracania danych z loaderów i actions. Remix oferuje helper functions jak json(), redirect() czy defer(), ale pod spodem to standardowe Response objects. Developer może ustawiać własne status codes, headers czy cookies używając standardowych Web API.
FormData API jest sercem obsługi formularzy w Remix. Zamiast kontrolowanych inputów z React state, Remix używa niekontrolowanych formularzy z natywnym zachowaniem przeglądarki. Po submicie, dane są automatycznie serializowane do FormData i wysyłane do action. To sprawia, że formularze działają nawet bez JavaScript, co jest fundamentem progressive enhancement.
Przykład kodu:
// Wykorzystanie Request API w loaderze
export async function loader({ request }: LoaderFunctionArgs) {
// Parsowanie URL i parametrów zapytania
const url = new URL(request.url);
const page = Number(url.searchParams.get("page")) || 1;
const sort = url.searchParams.get("sort") || "name";
// Odczyt nagłówków (np. cookies)
const cookieHeader = request.headers.get("Cookie");
const cookie = await parseCookie(cookieHeader);
// Sprawdzenie metody HTTP
if (request.method !== "GET") {
throw new Response("Method not allowed", { status: 405 });
}
const data = await fetchData({ page, sort, userId: cookie.userId });
// Zwracanie Response z custom headers
return json(data, {
headers: {
"Cache-Control": "max-age=300, s-maxage=3600",
"X-Custom-Header": "wartość"
}
});
}
// Wykorzystanie FormData w action
export async function action({ request }: ActionFunctionArgs) {
// Pobranie FormData ze standardowego Request
const formData = await request.formData();
// Odczyt wartości z różnych typów inputów
const email = formData.get("email"); // text input
const age = Number(formData.get("age")); // number input
const terms = formData.get("terms") === "on"; // checkbox
const role = formData.get("role"); // select/radio
// Obsługa multiple values (np. checkboxes)
const interests = formData.getAll("interests");
// Obsługa file uploads
const avatar = formData.get("avatar") as File;
if (avatar && avatar.size > 0) {
const buffer = await avatar.arrayBuffer();
await uploadFile(buffer, avatar.name);
}
// Walidacja
const errors: Record<string, string> = {};
if (!email?.includes("@")) {
errors.email = "Nieprawidłowy email";
}
if (Object.keys(errors).length > 0) {
// Zwracanie błędów walidacji jako Response
return json({ errors }, { status: 400 });
}
// Zapisanie danych
await createUser({ email, age, terms, role, interests });
// Redirect z custom header (np. flash message)
return redirect("/success", {
headers: {
"Set-Cookie": await createFlashCookie("Użytkownik utworzony!")
}
});
}
// Komponent wykorzystujący standardowy HTML form
export default function SignupForm() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
return (
// Natywny HTML form - działa bez JavaScript
<Form method="post" encType="multipart/form-data">
<div>
<input
name="email"
type="email"
required
aria-invalid={!!actionData?.errors?.email}
/>
{actionData?.errors?.email && (
<span className="error">{actionData.errors.email}</span>
)}
</div>
<input name="age" type="number" min="0" />
<label>
<input name="terms" type="checkbox" required />
Akceptuję regulamin
</label>
<select name="role">
<option value="user">Użytkownik</option>
<option value="admin">Administrator</option>
</select>
{/* Multiple checkboxes z tą samą nazwą */}
<fieldset>
<legend>Zainteresowania:</legend>
<label><input name="interests" type="checkbox" value="coding" /> Programowanie</label>
<label><input name="interests" type="checkbox" value="design" /> Design</label>
<label><input name="interests" type="checkbox" value="music" /> Muzyka</label>
</fieldset>
<input name="avatar" type="file" accept="image/*" />
<button type="submit" disabled={navigation.state === "submitting"}>
{navigation.state === "submitting" ? "Wysyłanie..." : "Zapisz"}
</button>
</Form>
);
}
// Zaawansowane użycie - streaming Response z defer()
export async function loader() {
// Dane krytyczne - czekamy na nie
const criticalData = await fetchCriticalData();
// Dane niekrytyczne - nie czekamy, streamujemy
const slowDataPromise = fetchSlowData();
return defer({
criticalData,
slowData: slowDataPromise // Promise, nie await!
});
}
export default function StreamingRoute() {
const data = useLoaderData<typeof loader>();
return (
<div>
{/* Renderowane natychmiast */}
<h1>{data.criticalData.title}</h1>
{/* Renderowane gdy Promise się resolve */}
<Suspense fallback={<div>Ładowanie...</div>}>
<Await resolve={data.slowData}>
{(slowData) => <div>{slowData.content}</div>}
</Await>
</Suspense>
</div>
);
}
Diagram:
sequenceDiagram
participant B as Przeglądarka
participant R as Remix Server
participant L as Loader/Action
participant DB as Baza Danych
B->>R: POST /signup (FormData)
R->>L: action({ request })
L->>L: await request.formData()
L->>L: Walidacja danych
L->>DB: Zapis w bazie
DB-->>L: Potwierdzenie
L->>R: Response (redirect/json)
R->>B: HTTP Response (302/200)
Note over B,DB: Wszystko oparte na Web Standards
Note over L: Request, Response, FormData
Note over B: Działa nawet bez JavaScript!
Materiały:
4. Czym jest progressive enhancement w kontekście Remix?
Odpowiedź w 30 sekund: Progressive enhancement w Remix oznacza, że aplikacja najpierw działa jako tradycyjna strona WWW z formularzami HTML i linkami, a następnie JavaScript stopniowo wzbogaca doświadczenie użytkownika o SPA-like interactions. Podstawowa funkcjonalność (formularze, nawigacja, wyświetlanie danych) działa bez JavaScript, a gdy JavaScript się załaduje, dodawane są optimistic UI, transitions i lepsze UX.
Odpowiedź w 2 minuty: Progressive enhancement to fundamentalna filozofia Remix, która odwraca tradycyjne podejście do budowania aplikacji React. Zamiast zaczynać od JavaScript i próbować dodać server-side rendering jako optymalizację, Remix zaczyna od w pełni funkcjonalnej aplikacji serwerowej, która następnie jest wzbogacana przez JavaScript.
W praktyce oznacza to, że każdy formularz w Remix działa jako standardowy HTML form z method="post" i action URL. Gdy użytkownik submituje formularz bez JavaScript, przeglądarka wykonuje standardowe HTTP POST request, serwer przetwarza dane w action, a następnie odsyła nową stronę. To jest baseline functionality, która zawsze działa.
Gdy JavaScript się załaduje, Remix automatycznie "przechwytuje" submit events i przekształca je w fetch requests. Użytkownik dostaje wtedy SPA experience - brak pełnego przeładowania strony, zachowanie scroll position, możliwość optimistic UI. Ale to wszystko jest enhancement - dodatkiem do działającej podstawy.
To podejście ma ogromne zalety: lepszy UX w wolnych sieciach (HTML działa zanim JavaScript się załaduje), lepsza dostępność (screen readers i inne assistive technologies lepiej radzą sobie ze standardowym HTML), większa odporność (jeśli JavaScript się nie załaduje lub wystąpi błąd JS, aplikacja nadal działa), i lepsze SEO (crawlers widzą w pełni funkcjonalną stronę).
Remix oferuje też hooks jak useNavigation(), useFetcher() i useTransition() do budowania zaawansowanych pending states, optimistic UI i loading indicators - ale to wszystko są enhancements na działającej podstawie.
Przykład kodu:
// Action - obsługuje zarówno JS jak i no-JS requests
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get("title");
const content = formData.get("content");
// Walidacja
const errors: Record<string, string> = {};
if (!title) errors.title = "Tytuł jest wymagany";
if (!content) errors.content = "Treść jest wymagana";
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 400 });
}
const post = await db.post.create({
data: { title: String(title), content: String(content) }
});
return redirect(`/posts/${post.id}`);
}
export default function NewPost() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
// Stan submitu - enhancement gdy JS jest włączony
const isSubmitting = navigation.state === "submitting";
const isSuccessful = navigation.state === "loading" &&
navigation.formData != null;
return (
<div>
{/* BASELINE: Zwykły HTML form - działa bez JavaScript */}
<Form method="post">
<div>
<label htmlFor="title">Tytuł:</label>
<input
id="title"
name="title"
type="text"
required
aria-invalid={!!actionData?.errors?.title}
aria-describedby={actionData?.errors?.title ? "title-error" : undefined}
/>
{/* Błędy walidacji - działają z i bez JS */}
{actionData?.errors?.title && (
<span id="title-error" className="error">
{actionData.errors.title}
</span>
)}
</div>
<div>
<label htmlFor="content">Treść:</label>
<textarea
id="content"
name="content"
required
aria-invalid={!!actionData?.errors?.content}
/>
{actionData?.errors?.content && (
<span className="error">{actionData.errors.content}</span>
)}
</div>
{/* BASELINE: Zwykły submit button */}
<button type="submit">
Opublikuj
</button>
{/* ENHANCEMENT: Pending state gdy JS działa */}
{isSubmitting && (
<div className="spinner" aria-live="polite">
Zapisywanie...
</div>
)}
{/* ENHANCEMENT: Success feedback */}
{isSuccessful && (
<div className="success" aria-live="polite">
Zapisano! Przekierowywanie...
</div>
)}
</Form>
</div>
);
}
// Zaawansowany przykład: Optimistic UI jako enhancement
export default function TodoList() {
const { todos } = useLoaderData<typeof loader>();
const fetcher = useFetcher();
// ENHANCEMENT: Optimistic UI - pokazuj zmianę natychmiast
const optimisticTodos = todos.map(todo => {
if (fetcher.formData?.get("todoId") === todo.id) {
// Symuluj zmianę przed odpowiedzią serwera
return {
...todo,
completed: fetcher.formData.get("completed") === "true"
};
}
return todo;
});
return (
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>
{/* BASELINE: Formularz który działa bez JS */}
<fetcher.Form method="post" action="/toggle-todo">
<input type="hidden" name="todoId" value={todo.id} />
<input
type="hidden"
name="completed"
value={String(!todo.completed)}
/>
<button type="submit">
{/* BASELINE: Pokazuje aktualny stan */}
{/* ENHANCEMENT: Pokazuje optimistic state */}
{todo.completed ? "✓" : "○"} {todo.title}
</button>
{/* ENHANCEMENT: Loading indicator */}
{fetcher.state === "submitting" && (
<span className="pending">...</span>
)}
</fetcher.Form>
</li>
))}
</ul>
);
}
// Przykład nawigacji z progressive enhancement
export function Navigation() {
const navigation = useNavigation();
return (
<nav>
{/* BASELINE: Zwykłe linki - działają bez JS */}
<Link to="/home">Home</Link>
<Link to="/about">O nas</Link>
<Link to="/contact">Kontakt</Link>
{/* ENHANCEMENT: Global loading indicator */}
{navigation.state === "loading" && (
<div className="global-spinner" aria-live="polite">
Ładowanie strony...
</div>
)}
</nav>
);
}
// Przykład wyszukiwania z debouncing jako enhancement
export default function SearchPage() {
const [searchParams] = useSearchParams();
const { results } = useLoaderData<typeof loader>();
const submit = useSubmit();
return (
<div>
{/* BASELINE: Formularz GET z instant search przez przeładowanie */}
<Form method="get">
<input
name="q"
type="search"
defaultValue={searchParams.get("q") ?? ""}
onChange={(e) => {
// ENHANCEMENT: Debounced submit gdy JS działa
const isFirstSearch = searchParams.get("q") === null;
submit(e.currentTarget.form, {
replace: !isFirstSearch, // Nie zapychaj historii
});
}}
/>
{/* BASELINE: Submit button dla no-JS */}
<button type="submit">Szukaj</button>
</Form>
<ul>
{results.map(result => (
<li key={result.id}>{result.name}</li>
))}
</ul>
</div>
);
}
Diagram:
flowchart TD
A[Użytkownik otwiera stronę] --> B[Serwer wysyła HTML]
B --> C{JavaScript załadowany?}
C -->|NIE| D[BASELINE: Zwykła strona HTML]
D --> E[Formularze = pełne przeładowanie]
D --> F[Linki = pełne przeładowanie]
D --> G[Brak loading states]
C -->|TAK| H[ENHANCEMENT: SPA Experience]
H --> I[Formularze = fetch requests]
H --> J[Linki = client-side routing]
H --> K[Optimistic UI]
H --> L[Loading indicators]
H --> M[Transitions]
E --> N[Aplikacja działa!]
F --> N
G --> N
I --> N
J --> N
K --> N
L --> N
M --> N
style D fill:#90EE90
style E fill:#90EE90
style F fill:#90EE90
style G fill:#90EE90
style H fill:#87CEEB
style I fill:#87CEEB
style J fill:#87CEEB
style K fill:#87CEEB
style L fill:#87CEEB
style M fill:#87CEEB
Materiały:
5. Jak działa architektura nested routing w Remix?
Odpowiedź w 30 sekund:
Nested routing w Remix pozwala tworzyć hierarchię tras, gdzie każdy poziom ma własny loader, action, error boundary i component. Parent routes renderują <Outlet /> dla child routes, a wszystkie loadery w hierarchii są wywoływane równolegle. To umożliwia izolację danych, błędów i UI na każdym poziomie zagnieżdżenia, eliminując "data waterfalls" i upraszając zarządzanie stanem.
Odpowiedź w 2 minuty: Nested routing jest jedną z najważniejszych feature Remix, zapożyczoną z React Router. Architektura pozwala na tworzenie hierarchii tras, gdzie każda trasa może mieć swoje własne dane, błędy i UI, które są kompozytowane razem w finalnej stronie.
Fundamentalna różnica w porównaniu do flat routing polega na tym, że parent route renderuje <Outlet /> component, który jest placeholderem dla child routes. Gdy użytkownik nawiguje do /dashboard/settings, Remix renderuje hierarchię: layout root -> dashboard route -> settings route. Każdy poziom może mieć własny loader do pobierania danych, własny action do obsługi mutacji, oraz własny error boundary do obsługi błędów.
Kluczową zaletą jest równoległe ładowanie danych - wszystkie loadery w hierarchii są wywoływane jednocześnie, nie sekwencyjnie. W tradycyjnym podejściu parent component musiałby się wyrenderować, pobrać dane, a dopiero potem child component mógłby zacząć pobierać swoje dane (data waterfall). Remix eliminuje ten problem wywołując wszystkie loadery równolegle w momencie matchowania route.
Nested routing pozwala też na partial revalidation - gdy wykonasz action na child route, Remix automatycznie rewaliduje dane tylko w tej trasie i jej parent routes, nie w całej aplikacji. Error boundaries działają podobnie - błąd w child route nie crashuje całej aplikacji, tylko pokazuje error UI w miejscu tego route, podczas gdy reszta strony działa normalnie.
To podejście promuje lepszą separację odpowiedzialności - każdy route jest odpowiedzialny tylko za swoje dane i UI, co ułatwia reasoning o kodzie, testowanie i utrzymanie aplikacji.
Przykład kodu:
// app/root.tsx - Root layout
export async function loader() {
// Dane globalne dostępne w całej aplikacji
return json({
user: await getUser(),
env: process.env.PUBLIC_ENV
});
}
export default function Root() {
const { user } = useLoaderData<typeof loader>();
return (
<html lang="pl">
<head>
<Meta />
<Links />
</head>
<body>
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/dashboard">Dashboard</Link>
{user ? <span>{user.name}</span> : <Link to="/login">Login</Link>}
</nav>
</header>
{/* Outlet renderuje matched child routes */}
<main>
<Outlet />
</main>
<footer>© 2025</footer>
<Scripts />
</body>
</html>
);
}
// app/routes/dashboard.tsx - Parent route z layoutem
export async function loader({ request }: LoaderFunctionArgs) {
// Wymuszenie autoryzacji
const user = await requireAuth(request);
// Dane współdzielone przez wszystkie child routes dashboard
return json({
user,
stats: await getUserStats(user.id)
});
}
export default function Dashboard() {
const { user, stats } = useLoaderData<typeof loader>();
return (
<div className="dashboard">
<aside>
<h2>Dashboard - {user.name}</h2>
<nav>
{/* Nested navigation */}
<NavLink to="/dashboard">Przegląd</NavLink>
<NavLink to="/dashboard/settings">Ustawienia</NavLink>
<NavLink to="/dashboard/projects">Projekty</NavLink>
<NavLink to="/dashboard/billing">Płatności</NavLink>
</nav>
<div className="stats">
<p>Projekty: {stats.projectCount}</p>
<p>Członkostwo: {stats.membershipLevel}</p>
</div>
</aside>
<div className="content">
{/* Child routes renderują się tutaj */}
<Outlet />
</div>
</div>
);
}
// Error boundary dla całego dashboardu
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 401) {
return (
<div>
<h1>Brak autoryzacji</h1>
<Link to="/login">Zaloguj się</Link>
</div>
);
}
return <div>Wystąpił błąd w dashboardzie</div>;
}
// app/routes/dashboard._index.tsx - Default child route (przegląd)
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAuth(request);
// Loader wywołany równolegle z parent loader!
return json({
recentActivity: await getRecentActivity(user.id),
notifications: await getNotifications(user.id)
});
}
export default function DashboardIndex() {
const { recentActivity, notifications } = useLoaderData<typeof loader>();
// Dostęp do danych z parent route przez useMatches()
const matches = useMatches();
const dashboardData = matches.find(m => m.id === "routes/dashboard")?.data;
return (
<div>
<h1>Przegląd</h1>
<section>
<h2>Powiadomienia ({notifications.length})</h2>
<ul>
{notifications.map(n => (
<li key={n.id}>{n.message}</li>
))}
</ul>
</section>
<section>
<h2>Ostatnia aktywność</h2>
<ul>
{recentActivity.map(a => (
<li key={a.id}>{a.description}</li>
))}
</ul>
</section>
</div>
);
}
// app/routes/dashboard.settings.tsx - Child route z własnym action
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAuth(request);
return json({
settings: await getUserSettings(user.id)
});
}
export async function action({ request }: ActionFunctionArgs) {
const user = await requireAuth(request);
const formData = await request.formData();
await updateUserSettings(user.id, {
theme: formData.get("theme"),
notifications: formData.get("notifications") === "on"
});
// Po action Remix automatycznie rewaliduje:
// - dashboard.settings loader (ta trasa)
// - dashboard loader (parent)
// - root loader (jeśli potrzebne)
return json({ success: true });
}
export default function Settings() {
const { settings } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
return (
<div>
<h1>Ustawienia</h1>
{actionData?.success && (
<div className="alert-success">Zapisano zmiany!</div>
)}
<Form method="post">
<label>
Motyw:
<select name="theme" defaultValue={settings.theme}>
<option value="light">Jasny</option>
<option value="dark">Ciemny</option>
</select>
</label>
<label>
<input
type="checkbox"
name="notifications"
defaultChecked={settings.notifications}
/>
Powiadomienia email
</label>
<button type="submit" disabled={navigation.state === "submitting"}>
{navigation.state === "submitting" ? "Zapisywanie..." : "Zapisz"}
</button>
</Form>
</div>
);
}
// Własny error boundary tylko dla settings
export function ErrorBoundary() {
return (
<div className="error">
<h2>Nie można załadować ustawień</h2>
<p>Spróbuj ponownie później</p>
</div>
);
}
// app/routes/dashboard.projects.$projectId.tsx - Dynamic nested route
export async function loader({ params }: LoaderFunctionArgs) {
const project = await getProject(params.projectId!);
if (!project) {
throw new Response("Nie znaleziono projektu", { status: 404 });
}
return json({ project });
}
export default function ProjectDetails() {
const { project } = useLoaderData<typeof loader>();
return (
<div>
<h1>{project.name}</h1>
<p>{project.description}</p>
{/* Możliwość dalszego zagnieżdżania! */}
<Outlet />
</div>
);
}
// Przykład użycia useMatches() do dostępu do danych z parent routes
export function Breadcrumbs() {
const matches = useMatches();
return (
<nav aria-label="breadcrumb">
<ol>
{matches
.filter(match => match.handle?.breadcrumb)
.map((match, index) => (
<li key={index}>
<Link to={match.pathname}>
{match.handle.breadcrumb(match.data)}
</Link>
</li>
))}
</ol>
</nav>
);
}
// Dodanie breadcrumb handle w route
export const handle = {
breadcrumb: (data: any) => data.project.name
};
Diagram:
flowchart TD
subgraph "Struktura plików"
A[app/root.tsx] --> B[app/routes/dashboard.tsx]
B --> C[app/routes/dashboard._index.tsx]
B --> D[app/routes/dashboard.settings.tsx]
B --> E[app/routes/dashboard.projects.tsx]
E --> F[app/routes/dashboard.projects.$projectId.tsx]
end
subgraph "Renderowanie /dashboard/projects/123"
G[Root Layout] --> H[Dashboard Layout]
H --> I[Project Details]
end
subgraph "Ładowanie danych równoległe"
J[Request: /dashboard/projects/123] --> K[Match Routes]
K --> L1[root.loader]
K --> L2[dashboard.loader]
K --> L3[projects.$projectId.loader]
L1 --> M[Combine Data]
L2 --> M
L3 --> M
M --> N[Render HTML]
end
style L1 fill:#87CEEB
style L2 fill:#87CEEB
style L3 fill:#87CEEB
style M fill:#90EE90
Diagram przepływu błędów:
flowchart TD
A[Błąd w Child Route] --> B{Error Boundary w Child?}
B -->|TAK| C[Renderuj Error UI w Child]
B -->|NIE| D{Error Boundary w Parent?}
D -->|TAK| E[Renderuj Error UI w Parent]
D -->|NIE| F{Error Boundary w Root?}
F -->|TAK| G[Renderuj Error UI w Root]
F -->|NIE| H[Default Error Page]
C --> I[Reszta strony działa normalnie]
E --> I
style C fill:#90EE90
style E fill:#FFD700
style G fill:#FFA500
style H fill:#FF6347
Materiały:
Routing
6. Jak działa system routingu oparty na plikach w Remix?
Odpowiedź w 30 sekund:
Remix wykorzystuje system routingu oparty na strukturze katalogów w folderze app/routes/. Każdy plik w tym katalogu automatycznie tworzy odpowiadającą mu ścieżkę URL. Nazwa pliku determinuje ścieżkę, a zagnieżdżenie katalogów pozwala na tworzenie hierarchicznych layoutów.
Odpowiedź w 2 minuty:
System routingu w Remix opiera się na konwencji "file-based routing", gdzie struktura plików bezpośrednio odzwierciedla strukturę URL aplikacji. Pliki umieszczone w katalogu app/routes/ są automatycznie mapowane na ścieżki w aplikacji. Na przykład, plik app/routes/about.tsx staje się dostępny pod adresem /about.
Remix wspiera różne konwencje nazewnictwa plików. Możesz używać notacji z kropkami (np. posts.new.tsx dla /posts/new) lub zagnieżdżonych katalogów (np. posts/new.tsx dla tej samej ścieżki). System automatycznie obsługuje pliki specjalne jak _index.tsx (route dla ścieżki nadrzędnej) oraz _layout.tsx (layout bez wpływu na URL).
Kluczową zaletą tego podejścia jest automatyczna optymalizacja ładowania kodu - Remix wie dokładnie, które komponenty i dane są potrzebne dla danej ścieżki i ładuje tylko niezbędne zasoby. Zagnieżdżone route'y automatycznie dziedziczą dane i błędy z route'ów nadrzędnych, co pozwala na eleganckie zarządzanie stanem i obsługą błędów w całej hierarchii.
Przykład kodu:
// Struktura katalogów i odpowiadające im ścieżki:
// app/routes/_index.tsx -> "/"
export default function Index() {
return <h1>Strona główna</h1>;
}
// app/routes/about.tsx -> "/about"
export default function About() {
return <h1>O nas</h1>;
}
// app/routes/blog._index.tsx -> "/blog"
export default function BlogIndex() {
return <h1>Lista postów</h1>;
}
// app/routes/blog.$slug.tsx -> "/blog/:slug"
export default function BlogPost() {
return <h1>Pojedynczy post</h1>;
}
// Alternatywnie z zagnieżdżonymi katalogami:
// app/routes/blog/index.tsx -> "/blog"
// app/routes/blog/$slug.tsx -> "/blog/:slug"
Diagram:
flowchart TD
A[app/routes/] --> B[_index.tsx<br/>/]
A --> C[about.tsx<br/>/about]
A --> D[blog._index.tsx<br/>/blog]
A --> E[blog.$slug.tsx<br/>/blog/:slug]
A --> F[products/]
F --> G[_index.tsx<br/>/products]
F --> H[$id.tsx<br/>/products/:id]
style A fill:#e1f5ff
style B fill:#fff4e6
style C fill:#fff4e6
style D fill:#fff4e6
style E fill:#ffe6e6
style F fill:#e1f5ff
style G fill:#fff4e6
style H fill:#ffe6e6
Materiały
7. Czym są segmenty dynamiczne ($param) i jak je definiować?
Odpowiedź w 30 sekund:
Segmenty dynamiczne w Remix to parametry URL oznaczane prefiksem $ w nazwie pliku. Plik app/routes/users.$id.tsx będzie obsługiwał ścieżki jak /users/123 lub /users/abc, gdzie wartość $id jest dostępna przez params.id w loaderze i komponencie.
Odpowiedź w 2 minuty:
Segmenty dynamiczne pozwalają na tworzenie route'ów, które obsługują zmienne wartości w URL. W Remix definiujemy je poprzez dodanie prefiksu $ przed nazwą parametru w nazwie pliku. Na przykład $userId.tsx stworzy segment dynamiczny, który może pasować do dowolnej wartości w tym miejscu URL.
Wartości segmentów dynamicznych są dostępne poprzez obiekt params zarówno w funkcji loader jak i w komponencie (przez hook useParams()). Możesz mieć wiele segmentów dynamicznych w jednej ścieżce, np. users.$userId.posts.$postId.tsx dla /users/123/posts/456. Remix automatycznie parsuje te wartości i udostępnia je jako właściwości obiektu params.
Ważne jest, że segmenty dynamiczne mają niższy priorytet niż statyczne - jeśli masz zarówno users.me.tsx jak i users.$id.tsx, to dla URL /users/me zostanie użyty statyczny route. To pozwala na tworzenie wyjątków dla specjalnych przypadków. Segmenty dynamiczne są podstawowym narzędziem do budowania aplikacji z dynamiczną treścią, jak blogi, sklepy internetowe czy panele administracyjne.
Przykład kodu:
// app/routes/users.$userId.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useParams } from "@remix-run/react";
// Loader ma dostęp do parametrów
export async function loader({ params }: LoaderFunctionArgs) {
const userId = params.userId; // wartość z URL
const user = await getUserById(userId);
if (!user) {
throw new Response("Nie znaleziono użytkownika", { status: 404 });
}
return json({ user });
}
export default function UserProfile() {
const { user } = useLoaderData<typeof loader>();
const params = useParams(); // alternatywny dostęp do params
return (
<div>
<h1>Profil użytkownika {params.userId}</h1>
<p>Imię: {user.name}</p>
<p>Email: {user.email}</p>
</div>
);
}
// Przykład z wieloma parametrami:
// app/routes/users.$userId.posts.$postId.tsx
export async function loader({ params }: LoaderFunctionArgs) {
const { userId, postId } = params;
const post = await getPost(userId, postId);
return json({ post });
}
Diagram:
flowchart LR
A[/users/123] --> B{Router}
B --> C[users.$userId.tsx]
C --> D[params.userId = '123']
D --> E[loader otrzymuje params]
E --> F[Komponent używa useLoaderData]
G[/users/123/posts/456] --> H{Router}
H --> I[users.$userId.posts.$postId.tsx]
I --> J[params.userId = '123'<br/>params.postId = '456']
style C fill:#e6f3ff
style I fill:#e6f3ff
style D fill:#ffe6e6
style J fill:#ffe6e6
Materiały
8. Jak działają splat routes ($) w Remix?
Odpowiedź w 30 sekund:
Splat routes (oznaczane jako $.tsx lub $filename.tsx z dodatkowym * w konwencji) to specjalny typ route'ów, które przechwytują wszystkie segmenty URL po danym punkcie. Są idealne do obsługi hierarchii plików, dokumentacji wielopoziomowej czy catchall route'ów.
Odpowiedź w 2 minuty:
Splat routes w Remix to zaawansowana funkcja routingu, która pozwala na przechwytywanie dowolnej liczby segmentów URL jako jednego parametru. Definiujemy je używając $.tsx jako nazwy pliku. Na przykład, plik app/routes/docs.$.tsx będzie pasował do /docs/a, /docs/a/b, /docs/a/b/c i tak dalej.
Wartość przechwyconą przez splat route można odczytać przez params["*"] - będzie to string zawierający wszystkie segmenty po ścieżce bazowej, połączone slashami. To niezwykle przydatne przy budowaniu systemów dokumentacji, gdzie struktura może być dowolnie zagnieżdżona, lub przy tworzeniu file browserów, gdzie chcemy obsłużyć ścieżki do plików o dowolnej głębokości.
Splat routes mają najniższy priorytet w systemie routingu Remix - najpierw będą sprawdzane route'y statyczne, potem z dynamicznymi segmentami, a na końcu splat routes. To pozwala na tworzenie "catchall" route'ów, które obsłużą wszystkie niezdefiniowane ścieżki. Możesz użyć ich również do tworzenia własnych 404 stron dla konkretnych sekcji aplikacji.
Przykład kodu:
// app/routes/docs.$.tsx - obsługuje /docs/* (dowolna ilość segmentów)
import { json, LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
// params["*"] zawiera wszystkie segmenty po /docs/
const splat = params["*"]; // np. "getting-started/installation" dla /docs/getting-started/installation
// Możemy parsować ścieżkę aby znaleźć odpowiedni dokument
const docPath = splat || "index";
const doc = await getDocumentation(docPath);
if (!doc) {
throw new Response("Nie znaleziono dokumentacji", { status: 404 });
}
return json({ doc, path: docPath });
}
export default function Docs() {
const { doc, path } = useLoaderData<typeof loader>();
return (
<div>
<nav>
<p>Ścieżka: {path}</p>
</nav>
<article>
<h1>{doc.title}</h1>
<div dangerouslySetInnerHTML={{ __html: doc.content }} />
</article>
</div>
);
}
// app/routes/files.$.tsx - przeglądarka plików
export async function loader({ params }: LoaderFunctionArgs) {
const filepath = params["*"] || "";
const segments = filepath.split("/").filter(Boolean);
// Obsługa hierarchii plików
const files = await getFilesAtPath(segments);
return json({
files,
currentPath: filepath,
breadcrumbs: segments
});
}
// Przykład catchall route dla 404
// app/routes/$.tsx - przechwytuje wszystkie niezdefiniowane ścieżki
export default function NotFound() {
return (
<div>
<h1>404 - Strona nie została znaleziona</h1>
<p>Przepraszamy, ta strona nie istnieje.</p>
</div>
);
}
Diagram:
flowchart TD
A[URL: /docs/guide/getting-started/intro] --> B{Remix Router}
B --> C[Sprawdź statyczne route'y<br/>docs.guide.getting-started.intro.tsx]
C -->|Nie znaleziono| D[Sprawdź dynamiczne segmenty<br/>docs.$category.$page.tsx]
D -->|Nie znaleziono| E[Sprawdź splat routes<br/>docs.$.tsx]
E --> F[params['*'] = 'guide/getting-started/intro']
F --> G[Loader przetwarza pełną ścieżkę]
G --> H[Renderowanie komponentu]
I[Przykłady dopasowania] --> J[/docs/a → params['*'] = 'a']
I --> K[/docs/a/b/c → params['*'] = 'a/b/c']
I --> L[/docs/ → params['*'] = undefined]
style E fill:#ffe6e6
style F fill:#fff4e6
style B fill:#e6f3ff
Materiały
9. Czym są pathless routes (układy bez ścieżki) i do czego służą?
Odpowiedź w 30 sekund:
Pathless routes to specjalne route'y oznaczone podkreślnikiem _ w nazwie, które nie dodają segmentu do URL, ale pozwalają na grupowanie route'ów i współdzielenie layoutów, loaderów czy obsługi błędów. Są idealne do organizacji kodu i tworzenia wspólnej logiki dla grupy stron.
Odpowiedź w 2 minuty:
Pathless routes (układy bez ścieżki) to potężna funkcja Remix, która pozwala na tworzenie route'ów wpływających na strukturę komponentów i zachowanie aplikacji, ale niewidocznych w URL. Oznaczamy je prefiksem podkreślnika _ w nazwie pliku, np. _layout.tsx lub _auth.tsx.
Głównym zastosowaniem pathless routes jest współdzielenie layoutów, logiki i obsługi błędów między grupą route'ów bez wpływu na strukturę URL. Na przykład, możesz utworzyć app/routes/_auth.tsx jako layout, a następnie app/routes/_auth.login.tsx i app/routes/_auth.register.tsx - obie strony będą dostępne pod /login i /register (bez /auth/), ale będą korzystać ze wspólnego layoutu i logiki z _auth.tsx.
Pathless routes są szczególnie przydatne przy tworzeniu grup route'ów wymagających wspólnej autoryzacji, wspólnego layoutu interfejsu, lub wspólnej obsługi błędów. Możesz w nich umieścić loader sprawdzający uprawnienia użytkownika, który będzie automatycznie wykonywany dla wszystkich zagnieżdżonych route'ów. To znacznie redukuje duplikację kodu i ułatwia utrzymanie spójności w aplikacji.
Przykład kodu:
// app/routes/_auth.tsx - pathless layout dla stron autoryzacyjnych
import { json, LoaderFunctionArgs, redirect } from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getUser(request);
// Jeśli użytkownik jest zalogowany, przekieruj do dashboardu
if (user) {
return redirect("/dashboard");
}
return json({ message: "Zaloguj się aby kontynuować" });
}
export default function AuthLayout() {
const { message } = useLoaderData<typeof loader>();
return (
<div className="auth-container">
<header>
<h1>Witamy w aplikacji</h1>
<p>{message}</p>
</header>
<main>
{/* Tutaj renderują się zagnieżdżone route'y */}
<Outlet />
</main>
<footer>
<p>© 2024 Nasza Aplikacja</p>
</footer>
</div>
);
}
// app/routes/_auth.login.tsx -> URL: /login (nie /_auth/login)
export default function Login() {
return (
<form method="post">
<h2>Logowanie</h2>
<input type="email" name="email" placeholder="Email" />
<input type="password" name="password" placeholder="Hasło" />
<button type="submit">Zaloguj</button>
</form>
);
}
// app/routes/_auth.register.tsx -> URL: /register
export default function Register() {
return (
<form method="post">
<h2>Rejestracja</h2>
<input type="text" name="name" placeholder="Imię" />
<input type="email" name="email" placeholder="Email" />
<input type="password" name="password" placeholder="Hasło" />
<button type="submit">Zarejestruj</button>
</form>
);
}
// Przykład z zagnieżdżonymi pathless routes
// app/routes/_marketing.tsx - wspólny layout dla stron marketingowych
export default function MarketingLayout() {
return (
<div>
<nav>{/* Nawigacja marketingowa */}</nav>
<Outlet />
</div>
);
}
// app/routes/_marketing._index.tsx -> URL: / (strona główna)
// app/routes/_marketing.about.tsx -> URL: /about
// app/routes/_marketing.pricing.tsx -> URL: /pricing
Diagram:
flowchart TD
A[Struktura plików] --> B[_auth.tsx<br/>pathless layout]
B --> C[_auth.login.tsx<br/>URL: /login]
B --> D[_auth.register.tsx<br/>URL: /register]
B --> E[_auth.forgot-password.tsx<br/>URL: /forgot-password]
F[Hierarchia komponentów] --> G[_auth Layout]
G --> H[Login Component]
G --> I[Register Component]
G --> J[Forgot Password Component]
K[Wszystkie route'y<br/>dzielą:] --> L[Wspólny loader<br/>sprawdzenie autoryzacji]
K --> M[Wspólny layout<br/>header, footer]
K --> N[Wspólna obsługa błędów<br/>ErrorBoundary]
style B fill:#ffe6e6
style G fill:#ffe6e6
style C fill:#e6f3ff
style D fill:#e6f3ff
style E fill:#e6f3ff
Materiały
10. Jak zaimplementować zagnieżdżone layouty w Remix?
Odpowiedź w 30 sekund:
Zagnieżdżone layouty w Remix implementuje się przez strukturę plików i komponent <Outlet />. Route nadrzędny renderuje <Outlet />, który zostaje zastąpiony przez komponent route'a potomnego. To pozwala na tworzenie hierarchicznych layoutów, gdzie każdy poziom może mieć własny UI i logikę.
Odpowiedź w 2 minuty:
Zagnieżdżone layouty to jedna z najważniejszych funkcji Remix, pozwalająca na tworzenie złożonych interfejsów z hierarchiczną strukturą. Implementujesz je poprzez odpowiednią strukturę plików i użycie komponentu <Outlet /> z @remix-run/react. Każdy route, który ma route'y potomne, powinien renderować <Outlet /> w miejscu, gdzie ma pojawić się zawartość dzieci.
Struktura katalogów bezpośrednio odzwierciedla hierarchię layoutów. Na przykład, app/routes/dashboard.tsx może zawierać layout z nawigacją boczną i renderować <Outlet />, a route'y jak app/routes/dashboard.settings.tsx czy app/routes/dashboard.profile.tsx będą renderowane wewnątrz tego layoutu. Każdy poziom hierarchii może mieć własny loader do ładowania danych i własny ErrorBoundary do obsługi błędów.
Kluczową zaletą tego podejścia jest automatyczna optymalizacja - Remix ładuje dane dla wszystkich poziomów hierarchii równolegle, a nie sekwencyjnie. Dodatkowo, przy nawigacji między route'ami tego samego layoutu, layout nie jest ponownie renderowany, co znacznie poprawia wydajność. Możesz również używać pathless routes (_layout) do tworzenia layoutów, które nie wpływają na URL.
Przykład kodu:
// app/routes/dashboard.tsx - główny layout dashboardu
import { json, LoaderFunctionArgs } from "@remix-run/node";
import { Link, Outlet, useLoaderData } from "@remix-run/react";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAuthenticatedUser(request);
return json({ user });
}
export default function DashboardLayout() {
const { user } = useLoaderData<typeof loader>();
return (
<div className="dashboard">
<aside className="sidebar">
<h2>Dashboard</h2>
<nav>
<Link to="/dashboard">Przegląd</Link>
<Link to="/dashboard/profile">Profil</Link>
<Link to="/dashboard/settings">Ustawienia</Link>
<Link to="/dashboard/projects">Projekty</Link>
</nav>
</aside>
<main className="content">
<header>
<h1>Witaj, {user.name}!</h1>
</header>
{/* Tutaj renderują się zagnieżdżone route'y */}
<Outlet />
</main>
</div>
);
}
// app/routes/dashboard._index.tsx - /dashboard (przegląd)
export async function loader() {
const stats = await getDashboardStats();
return json({ stats });
}
export default function DashboardIndex() {
const { stats } = useLoaderData<typeof loader>();
return (
<div>
<h2>Przegląd</h2>
<div className="stats">
<div>Projekty: {stats.projectCount}</div>
<div>Zadania: {stats.taskCount}</div>
</div>
</div>
);
}
// app/routes/dashboard.profile.tsx - /dashboard/profile
export default function Profile() {
return (
<div>
<h2>Twój profil</h2>
{/* Zawartość profilu */}
</div>
);
}
// app/routes/dashboard.projects.tsx - kolejny poziom zagnieżdżenia
export default function ProjectsLayout() {
return (
<div>
<h2>Projekty</h2>
<nav>
<Link to="/dashboard/projects">Lista</Link>
<Link to="/dashboard/projects/new">Nowy projekt</Link>
</nav>
{/* Outlet dla jeszcze bardziej zagnieżdżonych route'ów */}
<Outlet />
</div>
);
}
// app/routes/dashboard.projects._index.tsx - /dashboard/projects
export default function ProjectsList() {
return <div>Lista projektów</div>;
}
// app/routes/dashboard.projects.new.tsx - /dashboard/projects/new
export default function NewProject() {
return <div>Tworzenie nowego projektu</div>;
}
// app/routes/dashboard.projects.$projectId.tsx - /dashboard/projects/:projectId
export default function ProjectDetail() {
return <div>Szczegóły projektu</div>;
}
Diagram:
flowchart TD
A[root.tsx] --> B[dashboard.tsx<br/>Layout z sidebar]
B --> C[dashboard._index.tsx<br/>/dashboard]
B --> D[dashboard.profile.tsx<br/>/dashboard/profile]
B --> E[dashboard.settings.tsx<br/>/dashboard/settings]
B --> F[dashboard.projects.tsx<br/>Layout dla projektów]
F --> G[dashboard.projects._index.tsx<br/>/dashboard/projects]
F --> H[dashboard.projects.new.tsx<br/>/dashboard/projects/new]
F --> I[dashboard.projects.$projectId.tsx<br/>/dashboard/projects/:id]
J[Renderowanie dla /dashboard/projects/123] --> K[root.tsx renderuje Outlet]
K --> L[dashboard.tsx renderuje Outlet]
L --> M[dashboard.projects.tsx renderuje Outlet]
M --> N[dashboard.projects.$projectId.tsx<br/>final content]
style A fill:#e1f5ff
style B fill:#fff4e6
style F fill:#ffe6e6
style I fill:#e6ffe6
Materiały
11. Czym jest plik root.tsx i jaką pełni rolę?
Odpowiedź w 30 sekund:
Plik root.tsx to główny route aplikacji Remix, który opakowuje wszystkie pozostałe route'y. Zawiera strukturę HTML dokumentu (<html>, <head>, <body>), globalne style i skrypty, oraz renderuje wszystkie zagnieżdżone route'y przez komponent <Outlet />. Jest punktem wejścia dla całej aplikacji.
Odpowiedź w 2 minuty:
Plik app/root.tsx jest specjalnym route'em w Remix, który stanowi korzeń całej aplikacji. Jest to jedyny plik, który renderuje pełną strukturę HTML dokumentu, włączając tagi <html>, <head> i <body>. Wszystkie inne route'y są renderowane wewnątrz tego root route'a poprzez komponent <Outlet />.
W root.tsx definiujesz globalną strukturę aplikacji, metatagi, linki do stylów, skrypty oraz inne zasoby potrzebne na każdej stronie. Remix dostarcza specjalne komponenty jak <Meta />, <Links />, <ScrollRestoration /> i <Scripts />, które automatycznie wstrzykują odpowiednie tagi w odpowiednich miejscach. Możesz też dodać globalny ErrorBoundary i loader dla danych dostępnych w całej aplikacji (np. informacje o zalogowanym użytkowniku).
Root.tsx to również miejsce, gdzie możesz zaimplementować globalne UI elementy jak powiadomienia toast, modale, czy globalne loading indicators. Dzięki temu, że jest to route jak każdy inny, możesz używać wszystkich funkcji Remix jak loader, action, ErrorBoundary czy CatchBoundary. Jest to foundation, na którym budowana jest reszta aplikacji.
Przykład kodu:
// app/root.tsx
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
useRouteError,
isRouteErrorResponse,
} from "@remix-run/react";
import { json } from "@remix-run/node";
import stylesheet from "~/styles/global.css";
// Definiowanie globalnych linków do stylów
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesheet },
{ rel: "icon", href: "/favicon.ico" },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
];
// Loader dla globalnych danych
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getOptionalUser(request);
const env = {
NODE_ENV: process.env.NODE_ENV,
PUBLIC_API_URL: process.env.PUBLIC_API_URL,
};
return json({ user, env });
}
// Główny komponent aplikacji
export default function App() {
const { user, env } = useLoaderData<typeof loader>();
return (
<html lang="pl">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/* Meta tagi z wszystkich route'ów */}
<Meta />
{/* Linki do stylów z wszystkich route'ów */}
<Links />
</head>
<body>
{/* Globalny header */}
<header>
<nav>
<a href="/">Strona główna</a>
{user ? (
<>
<a href="/dashboard">Dashboard</a>
<span>Zalogowany jako: {user.name}</span>
</>
) : (
<a href="/login">Zaloguj się</a>
)}
</nav>
</header>
{/* Tutaj renderują się wszystkie route'y */}
<Outlet />
{/* Globalny footer */}
<footer>
<p>© 2024 Moja Aplikacja</p>
</footer>
{/* Przywracanie pozycji scrollu przy nawigacji */}
<ScrollRestoration />
{/* Skrypty z wszystkich route'ów */}
<Scripts />
{/* Hot reload w development */}
<LiveReload />
{/* Przekazanie zmiennych środowiskowych do klienta */}
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(env)}`,
}}
/>
</body>
</html>
);
}
// Globalny ErrorBoundary
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<html lang="pl">
<head>
<title>Błąd {error.status}</title>
<Meta />
<Links />
</head>
<body>
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
<Scripts />
</body>
</html>
);
}
return (
<html lang="pl">
<head>
<title>Wystąpił błąd</title>
<Meta />
<Links />
</head>
<body>
<h1>Wystąpił nieoczekiwany błąd</h1>
<p>Przepraszamy za niedogodności.</p>
<Scripts />
</body>
</html>
);
}
Diagram:
flowchart TD
A[root.tsx] --> B[html element]
B --> C[head]
B --> D[body]
C --> E[Meta - metatagi]
C --> F[Links - style CSS]
D --> G[Global Header/Nav]
D --> H[Outlet - route'y potomne]
D --> I[Global Footer]
D --> J[ScrollRestoration]
D --> K[Scripts - JS]
D --> L[LiveReload dev]
H --> M[dashboard.tsx]
H --> N[blog.tsx]
H --> O[about.tsx]
M --> P[dashboard._index.tsx]
M --> Q[dashboard.settings.tsx]
style A fill:#ff6b6b
style H fill:#4ecdc4
style M fill:#ffe66d
style N fill:#ffe66d
style O fill:#ffe66d
Materiały
12. Jak obsługiwać parametry wyszukiwania (query params) w Remix?
Odpowiedź w 30 sekund:
Parametry wyszukiwania (query params) w Remix obsługujesz używając URL API w loaderze i hooka useSearchParams() w komponencie. W loaderze parsuj new URL(request.url).searchParams, a w komponencie używaj useSearchParams() do odczytu i modyfikacji parametrów URL z automatyczną synchronizacją z nawigacją.
Odpowiedź w 2 minuty:
Remix oferuje wygodne API do pracy z parametrami wyszukiwania (query parameters). Po stronie serwera, w funkcji loader lub action, otrzymujesz obiekt request, z którego możesz wyciągnąć URL i jego parametry używając standardowego Web API: new URL(request.url).searchParams. Obiekt searchParams implementuje interfejs URLSearchParams i pozwala na łatwy dostęp do wartości.
Po stronie klienta używasz hooka useSearchParams(), który zwraca krotkę zawierającą aktualny obiekt URLSearchParams oraz funkcję setter podobną do useState. Możesz odczytywać wartości używając metod jak .get(), .getAll(), a modyfikować używając settera. Remix automatycznie obsługuje nawigację i aktualizację URL gdy zmieniasz parametry.
Częstym wzorcem jest tworzenie formularzy wyszukiwania, które automatycznie aktualizują URL i ponownie wywołują loader z nowymi parametrami. Możesz używać zwykłych formularzy (<Form>) lub programowo zmieniać parametry. Remix automatycznie obsłuży serializację, nawigację i ponowne ładowanie danych. Parametry query są idealne do implementacji funkcji filtrowania, sortowania, paginacji i wyszukiwania.
Przykład kodu:
// app/routes/products.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const searchParams = url.searchParams;
// Odczyt parametrów wyszukiwania
const query = searchParams.get("q") || "";
const category = searchParams.get("category") || "all";
const sort = searchParams.get("sort") || "name";
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = parseInt(searchParams.get("limit") || "20", 10);
// Pobieranie produktów z filtrami
const products = await searchProducts({
query,
category,
sort,
page,
limit,
});
const totalCount = await getProductCount({ query, category });
return json({
products,
totalCount,
filters: { query, category, sort, page, limit }
});
}
export default function Products() {
const { products, totalCount, filters } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
// Funkcja pomocnicza do zmiany parametrów
const updateFilter = (key: string, value: string) => {
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(key, value);
} else {
newParams.delete(key);
}
// Reset strony przy zmianie filtrów
if (key !== "page") {
newParams.set("page", "1");
}
setSearchParams(newParams);
};
return (
<div>
<h1>Produkty</h1>
{/* Formularz wyszukiwania - automatycznie aktualizuje URL */}
<Form method="get">
<input
type="search"
name="q"
placeholder="Szukaj produktów..."
defaultValue={filters.query}
/>
<select
name="category"
defaultValue={filters.category}
onChange={(e) => updateFilter("category", e.target.value)}
>
<option value="all">Wszystkie kategorie</option>
<option value="electronics">Elektronika</option>
<option value="clothing">Odzież</option>
<option value="books">Książki</option>
</select>
<select
name="sort"
defaultValue={filters.sort}
onChange={(e) => updateFilter("sort", e.target.value)}
>
<option value="name">Nazwa</option>
<option value="price-asc">Cena: rosnąco</option>
<option value="price-desc">Cena: malejąco</option>
<option value="newest">Najnowsze</option>
</select>
<button type="submit">Szukaj</button>
</Form>
{/* Wyniki */}
<div>
<p>Znaleziono: {totalCount} produktów</p>
<ul>
{products.map(product => (
<li key={product.id}>{product.name} - {product.price} zł</li>
))}
</ul>
</div>
{/* Paginacja */}
<div className="pagination">
<button
disabled={filters.page === 1}
onClick={() => updateFilter("page", String(filters.page - 1))}
>
Poprzednia
</button>
<span>Strona {filters.page}</span>
<button
disabled={filters.page * filters.limit >= totalCount}
onClick={() => updateFilter("page", String(filters.page + 1))}
>
Następna
</button>
</div>
{/* Wyświetlanie aktualnych filtrów */}
<div className="active-filters">
{filters.query && (
<span>
Wyszukiwanie: {filters.query}
<button onClick={() => updateFilter("q", "")}>✕</button>
</span>
)}
{filters.category !== "all" && (
<span>
Kategoria: {filters.category}
<button onClick={() => updateFilter("category", "all")}>✕</button>
</span>
)}
</div>
</div>
);
}
// Programowa zmiana parametrów
function ExampleProgrammaticNavigation() {
const [searchParams, setSearchParams] = useSearchParams();
const addFilter = () => {
// Opcja 1: Tworzenie nowego obiektu URLSearchParams
const newParams = new URLSearchParams(searchParams);
newParams.set("color", "red");
newParams.set("size", "large");
setSearchParams(newParams);
// Opcja 2: Przekazanie obiektu
setSearchParams({ color: "red", size: "large" });
// Opcja 3: Funkcja aktualizująca (zachowuje poprzednie parametry)
setSearchParams(prev => {
prev.set("color", "red");
return prev;
});
};
return <button onClick={addFilter}>Dodaj filtry</button>;
}
Diagram:
flowchart TD
A[Użytkownik zmienia filtr] --> B{Typ zmiany}
B -->|Submit formularza| C[Form method=get]
B -->|Programowa zmiana| D[setSearchParams]
C --> E[URL aktualizuje się<br/>/products?q=laptop&category=electronics]
D --> E
E --> F[Remix wykrywa zmianę URL]
F --> G[Wywołanie loadera z nowym request.url]
G --> H[Parsowanie searchParams<br/>new URL.searchParams]
H --> I[Pobranie danych z nowymi filtrami]
I --> J[Re-render komponentu z nowymi danymi]
K[useSearchParams w komponencie] --> L[Odczyt: searchParams.get]
K --> M[Modyfikacja: setSearchParams]
style E fill:#ffe6e6
style H fill:#e6f3ff
style K fill:#fff4e6
Materiały
Loader i Pobieranie Danych
13. Czym jest funkcja loader i jak działa pobieranie danych w Remix?
Odpowiedź w 30 sekund:
Funkcja loader to funkcja serwerowa eksportowana z pliku trasy, która wykonuje się przed renderowaniem komponentu i dostarcza dane do UI. Działa wyłącznie po stronie serwera, ma dostęp do bazy danych i tajnych kluczy, a dane zwracane przez nią są automatycznie dostępne w komponencie przez hook useLoaderData().
Odpowiedź w 2 minuty:
Loader to fundamentalny mechanizm pobierania danych w Remix, który rewolucjonizuje sposób, w jaki myślimy o ładowaniu danych w aplikacjach React. W przeciwieństwie do tradycyjnego podejścia z useEffect, gdzie dane pobierane są po renderowaniu komponentu, loader wykonuje się przed renderowaniem - podobnie jak getServerSideProps w Next.js, ale z większą elastycznością.
Loader jest funkcją asynchroniczną, która musi zostać nazwana loader i wyeksportowana z pliku trasy. Wykonuje się zawsze po stronie serwera podczas nawigacji początkowej (SSR) oraz po stronie serwera dla kolejnych nawigacji (zwraca JSON przez fetch). Ma pełny dostęp do wszystkich zasobów serwerowych - bazy danych, API, systemu plików, zmiennych środowiskowych - bez obawy o wycieki danych do klienta.
Kluczową zaletą jest automatyczna rewalidacja: Remix wie, kiedy dane mogły się zmienić (po action, po nawigacji, przy używaniu useFetcher) i automatycznie wywołuje loadery ponownie. Wszystko to działa z natywną nawigacją przeglądarki - wsparcie dla przycisku "wstecz", otwieranie w nowej karcie, progressive enhancement.
Loader może zwracać różne typy odpowiedzi: JSON (najczęstsze), Response z custom headers, streamy (z defer), przekierowania, błędy. Remix automatycznie serializuje i deserializuje dane, obsługuje błędy przez najbliższy ErrorBoundary i zapewnia TypeScript type safety między loaderem a komponentem.
Przykład kodu:
// app/routes/products.$productId.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { db } from "~/db.server";
// Loader - wykonuje się po stronie serwera
export async function loader({ params, request }: LoaderFunctionArgs) {
const { productId } = params;
// Możemy bezpiecznie używać tajnych kluczy
const product = await db.product.findUnique({
where: { id: productId },
include: { reviews: true, category: true }
});
if (!product) {
// Remix automatycznie obsłuży to przez ErrorBoundary
throw new Response("Produkt nie znaleziony", { status: 404 });
}
// Możemy ustawić custom headers
return json(product, {
headers: {
"Cache-Control": "public, max-age=300"
}
});
}
// Komponent - dane są już dostępne przy pierwszym renderowaniu
export default function ProductPage() {
const product = useLoaderData<typeof loader>();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>Cena: {product.price} PLN</p>
<div>
<h2>Kategoria: {product.category.name}</h2>
<h3>Opinie ({product.reviews.length})</h3>
{product.reviews.map(review => (
<div key={review.id}>{review.text}</div>
))}
</div>
</div>
);
}
Diagram:
sequenceDiagram
participant Browser
participant RemixServer
participant Loader
participant Database
participant Component
Browser->>RemixServer: GET /products/123
RemixServer->>Loader: Wywołaj loader({ params, request })
Loader->>Database: Pobierz dane produktu
Database-->>Loader: Zwróć dane
Loader-->>RemixServer: return json(data)
RemixServer->>Component: Renderuj z danymi
Component-->>Browser: HTML z danymi (SSR)
Note over Browser,Component: Kolejna nawigacja (client-side)
Browser->>RemixServer: Fetch /products/456?_data=routes/products.$productId
RemixServer->>Loader: Wywołaj loader
Loader->>Database: Pobierz dane
Database-->>Loader: Zwróć dane
Loader-->>RemixServer: return json(data)
RemixServer-->>Browser: JSON
Browser->>Component: Re-render z nowymi danymi
Materiały:
14. Jak używać hooka useLoaderData do dostępu do danych?
Odpowiedź w 30 sekund:
Hook useLoaderData<typeof loader>() zwraca dane zwrócone przez funkcję loader trasy. Wykorzystuje TypeScript generics do automatycznego wnioskowania typów, zapewniając pełną type safety bez dodatkowej konfiguracji. Dane są dostępne synchronicznie w komponencie, ponieważ loader zawsze wykonuje się przed renderowaniem.
Odpowiedź w 2 minuty:
useLoaderData to podstawowy hook do dostępu do danych załadowanych przez loader. Co go wyróżnia od tradycyjnych rozwiązań, to fakt, że dane są zawsze dostępne - nie ma stanów "loading" czy "undefined". Remix gwarantuje, że komponent nie zostanie wyrenderowany, dopóki loader się nie zakończy (lub nie rzuci błędu).
Kluczową cechą jest integracja z TypeScript. Używając useLoaderData<typeof loader>(), TypeScript automatycznie inferencjonuje typy zwracane przez loader - nie musimy ręcznie definiować interfejsów czy typów. To działa dzięki temu, że TypeScript śledzi typ zwracany przez funkcję loader, włączając w to wszystkie transformacje,warunki i możliwe typy odpowiedzi.
Hook automatycznie aktualizuje się przy rewalidacji - gdy loader zostanie wywołany ponownie (np. po submicie formularza, po nawigacji, przy useFetcher.load()), komponent automatycznie otrzyma nowe dane i się przerenderuje. Nie trzeba ręcznie zarządzać stanem czy synchronizacją.
Ważna uwaga: useLoaderData zwraca dane tylko z loadera najbliższej trasy, nie z tras nadrzędnych. Jeśli potrzebujesz danych z trasy rodzica, użyj useRouteLoaderData(routeId). Hook działa tylko w komponencie trasy i jego potomnych - nie możesz go użyć w losowym komponencie poza drzewem tras Remix.
Przykład kodu:
// app/routes/dashboard.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useRouteLoaderData } from "@remix-run/react";
// Loader z różnymi typami danych
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getUser(request);
const stats = await getStats(user.id);
const notifications = await getNotifications(user.id);
return json({
user: {
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar
},
stats: {
totalOrders: stats.orders,
revenue: stats.revenue,
activeSubscriptions: stats.subscriptions
},
notifications: notifications.map(n => ({
id: n.id,
message: n.message,
read: n.read,
createdAt: n.createdAt
})),
timestamp: new Date().toISOString()
});
}
export default function Dashboard() {
// TypeScript automatycznie wie, jaki jest typ 'data'
const data = useLoaderData<typeof loader>();
// Mamy pełną type safety i autocomplete
return (
<div>
<header>
<img src={data.user.avatar} alt={data.user.name} />
<h1>Witaj, {data.user.name}</h1>
<p>{data.user.email}</p>
</header>
<div className="stats">
<StatCard
label="Zamówienia"
value={data.stats.totalOrders}
/>
<StatCard
label="Przychód"
value={`${data.stats.revenue} PLN`}
/>
<StatCard
label="Subskrypcje"
value={data.stats.activeSubscriptions}
/>
</div>
<NotificationList notifications={data.notifications} />
<footer>
<small>Dane z: {new Date(data.timestamp).toLocaleString()}</small>
</footer>
</div>
);
}
// Komponent potomny - ma dostęp do tych samych danych
function StatCard({ label, value }: { label: string; value: string | number }) {
// Ten komponent też może używać useLoaderData
const data = useLoaderData<typeof loader>();
return (
<div className="stat-card">
<h3>{label}</h3>
<p>{value}</p>
</div>
);
}
// app/routes/dashboard.settings.tsx
// Zagnieżdżona trasa - dostęp do danych z parent route
export default function DashboardSettings() {
// Dane z własnego loadera (jeśli istnieje)
const settingsData = useLoaderData<typeof loader>();
// Dane z parent route przez ID trasy
const dashboardData = useRouteLoaderData<typeof import("./dashboard").loader>(
"routes/dashboard"
);
return (
<div>
<h2>Ustawienia dla {dashboardData?.user.name}</h2>
{/* ... */}
</div>
);
}
Przykład z obsługą różnych typów zwracanych:
// app/routes/api.data.tsx
import { json, redirect, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getUser(request);
// Loader może zwracać różne typy
if (!user) {
// Przekierowanie - komponent się nie wykona
return redirect("/login");
}
if (!user.hasAccess) {
// Błąd - zostanie złapany przez ErrorBoundary
throw json(
{ message: "Brak dostępu" },
{ status: 403 }
);
}
// Normalny zwrot danych
return json({
data: await fetchData(user.id),
meta: { timestamp: Date.now() }
});
}
export default function ApiData() {
// TypeScript wie, że jeśli komponent się wykonuje,
// to user istnieje i ma dostęp (inaczej byłoby redirect/error)
const { data, meta } = useLoaderData<typeof loader>();
// Bezpieczne użycie danych - nie trzeba sprawdzać null/undefined
return (
<div>
<h1>Dane załadowane</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
<p>Timestamp: {meta.timestamp}</p>
</div>
);
}
Diagram:
flowchart TD
A[Nawigacja do trasy] --> B[Remix wywołuje loader]
B --> C{Loader zwraca...}
C -->|json data| D[Dane dostępne w useLoaderData]
C -->|redirect| E[Przekierowanie - komponent nie renderuje]
C -->|throw Response| F[ErrorBoundary obsługuje błąd]
D --> G[Komponent renderuje z danymi]
G --> H[Potomne komponenty mogą użyć useLoaderData]
I[Rewalidacja danych] --> B
J[Action wykonane] --> I
K[Nawigacja wstecz] --> I
L[useFetcher.load] --> I
Materiały:
15. Czym jest defer i jak implementować streaming danych?
Odpowiedź w 30 sekund:
defer to funkcja pozwalająca na streaming danych z serwera do klienta przez HTTP streaming. Umożliwia natychmiastowe wyrenderowanie strony z szybkimi danymi, podczas gdy wolne dane (np. z zewnętrznych API) są streamowane w tle i aktualizują UI w miarę dostępności, używając hooka useAsyncValue i komponentu <Await>.
Odpowiedź w 2 minuty: Defer to zaawansowana funkcja Remix wykorzystująca HTTP streaming do progresywnego ładowania danych. Tradycyjnie, loader musi poczekać na wszystkie dane przed zwróceniem odpowiedzi - jeśli jeden z requestów jest wolny, cała strona czeka. Defer rozwiązuje ten problem, pozwalając wysłać część danych natychmiast (strona się renderuje), a resztę przesłać później przez ten sam HTTP stream.
Mechanizm działa na bazie Promises - zwracamy z loadera obiekt zawierający zarówno już rozwiązane dane (zwykłe wartości), jak i nierozwiązane Promise'y. Remix automatycznie czeka na zwykłe wartości, ale Promise'y są przesyłane do klienta i rozwiązywane tam. Klient otrzymuje HTML z danymi, które były gotowe, plus placeholder dla danych oczekujących. Gdy Promise się rozwiąże po stronie serwera, Remix przesyła dane przez stream i aktualizuje UI.
W komponencie używamy <Suspense> z React i specjalnego komponentu <Await> z Remix. <Suspense> pokazuje fallback podczas ładowania, a <Await> czeka na rozwiązanie Promise i renderuje UI z danymi przez render prop lub children function. Hook useAsyncValue() wewnątrz <Await> daje dostęp do rozwiązanych danych.
Kluczowe przypadki użycia to: wolne API zewnętrzne (analytics, rekomendacje), dane priorytetowe vs drugorzędne (content vs komentarze), dashboard z wieloma źródłami danych, optymalizacja Core Web Vitals (LCP). Ważne: defer wymaga obsługi streaming przez hosting - działa na Vercel, Fly.io, własnym Node.js, ale nie na wszystkich platformach.
Przykład kodu:
// app/routes/product.$id.tsx
import { defer, type LoaderFunctionArgs } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
// Typy dla danych
interface Product {
id: string;
name: string;
price: number;
description: string;
}
interface Review {
id: string;
rating: number;
text: string;
author: string;
}
interface Recommendations {
products: Product[];
}
export async function loader({ params }: LoaderFunctionArgs) {
const { id } = params;
// Dane krytyczne - czekamy na nie (await)
const product = await db.product.findUnique({
where: { id }
});
if (!product) {
throw new Response("Not Found", { status: 404 });
}
// Dane drugorzędne - NIE czekamy (bez await)
// Te Promise'y zostaną rozwiązane później
const reviewsPromise = fetch(`https://api.reviews.com/products/${id}`)
.then(res => res.json())
.catch(() => ({ reviews: [] })); // Graceful fallback
const recommendationsPromise = fetch(`https://api.ml.com/recommendations/${id}`)
.then(res => res.json())
.catch(() => ({ products: [] }));
// defer zwraca Response z streamem
// Mieszamy gotowe dane i Promise'y
return defer({
product, // Już rozwiązane - dostępne natychmiast
reviews: reviewsPromise, // Promise - będzie streamowane
recommendations: recommendationsPromise // Promise - będzie streamowane
});
}
export default function ProductPage() {
const data = useLoaderData<typeof loader>();
// data.product jest już dostępny - strona renderuje się natychmiast
return (
<div>
<h1>{data.product.name}</h1>
<p>{data.product.description}</p>
<p className="price">{data.product.price} PLN</p>
{/* Opinie - Suspense pokazuje loader podczas oczekiwania */}
<Suspense
fallback={
<div className="loading">
Ładowanie opinii...
</div>
}
>
<Await
resolve={data.reviews}
errorElement={
<div className="error">
Nie udało się załadować opinii
</div>
}
>
{(reviews: { reviews: Review[] }) => (
<ReviewsList reviews={reviews.reviews} />
)}
</Await>
</Suspense>
{/* Rekomendacje - osobny Suspense, niezależne ładowanie */}
<Suspense
fallback={
<div className="loading">
Szukamy podobnych produktów...
</div>
}
>
<Await resolve={data.recommendations}>
{(recs: Recommendations) => (
<RecommendationsList products={recs.products} />
)}
</Await>
</Suspense>
</div>
);
}
// Komponent z useAsyncValue
function ReviewsList({ reviews }: { reviews: Review[] }) {
// Alternatywnie można użyć useAsyncValue() zamiast render prop
// const reviews = useAsyncValue() as { reviews: Review[] };
return (
<div className="reviews">
<h2>Opinie ({reviews.length})</h2>
{reviews.map(review => (
<div key={review.id} className="review">
<div className="rating">{'⭐'.repeat(review.rating)}</div>
<p>{review.text}</p>
<small>— {review.author}</small>
</div>
))}
</div>
);
}
Przykład z warunkowym defer:
// app/routes/dashboard.tsx
import { defer, json, type LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const fast = url.searchParams.get("fast") === "true";
// Szybkie dane - zawsze czekamy
const user = await getUser(request);
const basicStats = await getBasicStats(user.id);
// Wolne dane
const detailedAnalytics = getDetailedAnalytics(user.id); // Bez await
const externalData = fetchExternalData(user.id); // Bez await
// Warunkowy defer - dla szybkiego trybu czekamy na wszystko
if (fast) {
return json({
user,
basicStats,
detailedAnalytics: await detailedAnalytics,
externalData: await externalData
});
}
// W normalnym trybie streamujemy wolne dane
return defer({
user,
basicStats,
detailedAnalytics,
externalData
});
}
Przykład z zagnieżdżonymi Await:
// Wielopoziomowe ładowanie danych
export default function ComplexDashboard() {
const data = useLoaderData<typeof loader>();
return (
<div>
<h1>Dashboard dla {data.user.name}</h1>
{/* Poziom 1 - podstawowe dane */}
<BasicStats stats={data.basicStats} />
{/* Poziom 2 - analytics */}
<Suspense fallback={<AnalyticsSkeleton />}>
<Await resolve={data.detailedAnalytics}>
{(analytics) => (
<div>
<AnalyticsCharts data={analytics} />
{/* Poziom 3 - dane zależne od analytics */}
<Suspense fallback={<div>Ładowanie szczegółów...</div>}>
<Await resolve={data.externalData}>
{(external) => (
<ExternalDataView
data={external}
analytics={analytics}
/>
)}
</Await>
</Suspense>
</div>
)}
</Await>
</Suspense>
</div>
);
}
Diagram:
sequenceDiagram
participant Browser
participant RemixServer
participant FastDB
participant SlowAPI
Browser->>RemixServer: GET /product/123
RemixServer->>FastDB: Pobierz dane produktu (await)
FastDB-->>RemixServer: Produkt (200ms)
Note over RemixServer: defer() - rozpocznij stream
RemixServer-->>Browser: HTML + produkt (streaming started)
Browser->>Browser: Renderuj stronę z produktem
par Równoległe ładowanie w tle
RemixServer->>SlowAPI: Pobierz opinie (promise)
and
RemixServer->>SlowAPI: Pobierz rekomendacje (promise)
end
SlowAPI-->>RemixServer: Opinie (1.5s)
RemixServer-->>Browser: Stream chunk: opinie
Browser->>Browser: Aktualizuj UI - pokaż opinie
SlowAPI-->>RemixServer: Rekomendacje (2s)
RemixServer-->>Browser: Stream chunk: rekomendacje
Browser->>Browser: Aktualizuj UI - pokaż rekomendacje
RemixServer-->>Browser: Zamknij stream
Materiały:
16. Jak obsługiwać równoległe ładowanie danych w zagnieżdżonych trasach?
Odpowiedź w 30 sekund: Remix automatycznie wykonuje loadery wszystkich dopasowanych tras równolegle, a nie sekwencyjnie jak w tradycyjnych routerach. Gdy nawigujesz do zagnieżdżonej trasy, Remix wywołuje loadery trasy rodzica i dziecka jednocześnie, co znacząco przyspiesza ładowanie danych. Każda trasa zarządza własnymi danymi niezależnie, bez konieczności przekazywania przez props.
Odpowiedź w 2 minuty: Równoległe ładowanie danych to jedna z kluczowych optymalizacji Remix, która wyróżnia go na tle innych rozwiązań. W tradycyjnym podejściu (np. React Router przed v6.4), gdy mamy zagnieżdżone trasy, każdy poziom musi załadować swoje dane przed przejściem do następnego - to tzw. "waterfall". Remix rozwiązuje to przez równoległe wykonanie wszystkich loaderów dla wszystkich dopasowanych tras.
Gdy użytkownik nawiguje do trasy /dashboard/projects/123/tasks, Remix identyfikuje wszystkie dopasowane trasy w hierarchii: root, dashboard, dashboard.projects, dashboard.projects.$projectId, dashboard.projects.$projectId.tasks. Następnie wywołuje wszystkie ich loadery jednocześnie, równolegle. Każdy loader otrzymuje swoje parametry, request i context, i wykonuje się niezależnie.
To podejście ma głębokie konsekwencje architektoniczne. Po pierwsze, każda trasa jest odpowiedzialna tylko za swoje dane - nie ma "prop drilling" ani przekazywania danych w dół. Po drugie, eliminuje problemy z waterfalls - oszczędność czasu to (n-1) * round-trip-time, gdzie n to liczba poziomów zagnieżdżenia. Po trzecie, umożliwia lepszą izolację i testowalność - każdy loader to niezależna jednostka.
Ważne aspekty to obsługa błędów (błąd w jednym loaderze nie blokuje innych, każdy ma swój ErrorBoundary) i rewalidacja (po mutacji Remix rewaliduje tylko dotknięte loadery). Możemy też kontrolować, które loadery się rewalidują przez shouldRevalidate. Dane z parent route są dostępne przez useRouteLoaderData(routeId), co pozwala na współdzielenie danych bez duplikacji requestów.
Przykład kodu:
// app/root.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ request }: LoaderFunctionArgs) {
console.log("[ROOT] Loader started");
const user = await authenticateUser(request);
console.log("[ROOT] Loader finished");
return json({ user });
}
// app/routes/dashboard.tsx
export async function loader({ request }: LoaderFunctionArgs) {
console.log("[DASHBOARD] Loader started");
const stats = await fetchDashboardStats(request);
console.log("[DASHBOARD] Loader finished");
return json({ stats });
}
// app/routes/dashboard.projects.tsx
export async function loader({ request }: LoaderFunctionArgs) {
console.log("[PROJECTS] Loader started");
const projects = await fetchProjects(request);
console.log("[PROJECTS] Loader finished");
return json({ projects });
}
// app/routes/dashboard.projects.$projectId.tsx
export async function loader({ params }: LoaderFunctionArgs) {
console.log("[PROJECT DETAIL] Loader started");
const project = await fetchProject(params.projectId);
console.log("[PROJECT DETAIL] Loader finished");
return json({ project });
}
// app/routes/dashboard.projects.$projectId.tasks.tsx
export async function loader({ params }: LoaderFunctionArgs) {
console.log("[TASKS] Loader started");
const tasks = await fetchTasks(params.projectId);
console.log("[TASKS] Loader finished");
return json({ tasks });
}
// Przy nawigacji do /dashboard/projects/123/tasks
// Logi w konsoli (wszystkie "started" pojawią się jednocześnie):
// [ROOT] Loader started
// [DASHBOARD] Loader started
// [PROJECTS] Loader started
// [PROJECT DETAIL] Loader started
// [TASKS] Loader started
// ... (po zakończeniu wszystkich)
// [ROOT] Loader finished
// [DASHBOARD] Loader finished
// [PROJECTS] Loader finished
// [PROJECT DETAIL] Loader finished
// [TASKS] Loader finished
export default function ProjectTasks() {
// Własne dane
const { tasks } = useLoaderData<typeof loader>();
// Dane z parent routes
const projectData = useRouteLoaderData<typeof import("./dashboard.projects.$projectId").loader>(
"routes/dashboard.projects.$projectId"
);
const projectsData = useRouteLoaderData<typeof import("./dashboard.projects").loader>(
"routes/dashboard.projects"
);
return (
<div>
<h1>Zadania w projekcie: {projectData?.project.name}</h1>
<p>Wszystkich projektów: {projectsData?.projects.length}</p>
<ul>
{tasks.map(task => (
<li key={task.id}>{task.title}</li>
))}
</ul>
</div>
);
}
Przykład z optymalizacją i kontrolą rewalidacji:
// app/routes/dashboard.projects.$projectId.tasks.tsx
import { json, type LoaderFunctionArgs, type ShouldRevalidateFunction } from "@remix-run/node";
export async function loader({ params, request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const filter = url.searchParams.get("filter");
const sort = url.searchParams.get("sort");
// Tylko niezbędne dane dla tego poziomu
const tasks = await db.task.findMany({
where: {
projectId: params.projectId,
...(filter && { status: filter })
},
orderBy: sort ? { [sort]: 'asc' } : { createdAt: 'desc' }
});
return json({ tasks, filter, sort });
}
// Kontroluj, kiedy loader ma się re-wykonać
export const shouldRevalidate: ShouldRevalidateFunction = ({
actionResult,
currentParams,
currentUrl,
nextParams,
nextUrl,
defaultShouldRevalidate,
formAction,
formMethod
}) => {
// Nie rewaliduj jeśli zmieniła się tylko query string (np. filter)
// - to oszczędza niepotrzebne requesty
if (currentParams.projectId === nextParams.projectId) {
// Rewaliduj tylko po akcjach związanych z zadaniami
if (formMethod === "POST" || formMethod === "DELETE") {
return true;
}
// Nie rewaliduj przy zmianie tylko filtrów (to obsłuży client-side)
if (currentUrl.pathname === nextUrl.pathname) {
return false;
}
}
// W innych przypadkach użyj domyślnej logiki
return defaultShouldRevalidate;
};
export default function ProjectTasks() {
const { tasks, filter, sort } = useLoaderData<typeof loader>();
return (
<div>
{/* Filtry używają search params - client-side navigation */}
<TaskFilters currentFilter={filter} currentSort={sort} />
<TaskList tasks={tasks} />
</div>
);
}
Przykład z obsługą zależności między danymi:
// app/routes/dashboard.projects.$projectId.tsx
export async function loader({ params, request }: LoaderFunctionArgs) {
const project = await db.project.findUnique({
where: { id: params.projectId },
include: { team: true }
});
if (!project) {
throw new Response("Project not found", { status: 404 });
}
// Sprawdź uprawnienia
const user = await getUser(request);
const hasAccess = project.team.members.some(m => m.id === user.id);
if (!hasAccess) {
throw new Response("Forbidden", { status: 403 });
}
return json({ project });
}
// app/routes/dashboard.projects.$projectId.tasks.tsx
// Ten loader wykona się RÓWNOLEGLE z parent loader
// Ale jeśli parent rzuci 404/403, ten loader zostanie anulowany
export async function loader({ params }: LoaderFunctionArgs) {
// Możemy bezpiecznie założyć, że projekt istnieje i mamy dostęp
// Bo gdyby nie, parent loader by rzucił błąd
const tasks = await db.task.findMany({
where: { projectId: params.projectId }
});
return json({ tasks });
}
Diagram:
sequenceDiagram
participant Browser
participant Remix
participant RootLoader
participant DashboardLoader
participant ProjectsLoader
participant ProjectLoader
participant TasksLoader
Browser->>Remix: GET /dashboard/projects/123/tasks
Note over Remix: Identyfikuje dopasowane trasy
par Równoległe wykonanie loaderów
Remix->>RootLoader: loader({ request })
Remix->>DashboardLoader: loader({ request })
Remix->>ProjectsLoader: loader({ request })
Remix->>ProjectLoader: loader({ params: {projectId: "123"} })
Remix->>TasksLoader: loader({ params: {projectId: "123"} })
end
RootLoader-->>Remix: { user }
DashboardLoader-->>Remix: { stats }
ProjectsLoader-->>Remix: { projects }
ProjectLoader-->>Remix: { project }
TasksLoader-->>Remix: { tasks }
Note over Remix: Wszystkie dane gotowe - renderuj
Remix-->>Browser: HTML z wszystkimi danymi
Note over Browser,Remix: Porównanie z tradycyjnym podejściem (waterfall)
Browser->>Remix: GET /dashboard/projects/123/tasks (tradycyjne)
Remix->>RootLoader: loader()
RootLoader-->>Remix: data
Note over Remix: Czeka...
Remix->>DashboardLoader: loader()
DashboardLoader-->>Remix: data
Note over Remix: Czeka...
Remix->>ProjectsLoader: loader()
ProjectsLoader-->>Remix: data
Note over Remix: Czeka...
Note over Browser,Remix: 5x dłużej przy 5 poziomach!
Materiały:
17. Jak działa rewalidacja danych po mutacjach?
Odpowiedź w 30 sekund: Po wykonaniu action (mutacji danych), Remix automatycznie wywołuje ponownie wszystkie loadery na aktualnej stronie, aby zsynchronizować UI z najnowszymi danymi. Ten proces nazywa się rewalidacją i eliminuje potrzebę ręcznego zarządzania stanem - po zmianie danych na serwerze, UI automatycznie się aktualizuje z najświeższymi wartościami.
Odpowiedź w 2 minuty: Rewalidacja to automatyczny mechanizm Remix, który utrzymuje UI zsynchronizowane z danymi na serwerze. Po każdym wywołaniu action (submit formularza, useFetcher.submit), Remix automatycznie ponownie wykonuje loadery wszystkich dopasowanych tras, pobierając najświeższe dane. To fundamentalna różnica w porównaniu z tradycyjnymi SPA, gdzie developer musi ręcznie invalidować cache, aktualizować state czy wywoływać re-fetch.
Proces działa następująco: po zakończeniu action, Remix identyfikuje wszystkie trasy w obecnej hierarchii, wywołuje ich loadery równolegle (tak jak przy nawigacji), a gdy dane wrócą, automatycznie re-renderuje komponenty z nowymi wartościami z useLoaderData. Wszystko to działa bez JavaScript - z progressive enhancement formularze działają nawet gdy JS się nie załadował, bo Remix wykorzystuje natywne formularze i nawigację.
Możesz kontrolować rewalidację na kilka sposobów. Po pierwsze, funkcja shouldRevalidate w każdej trasie pozwala zdecydować, czy dany loader powinien się wykonać ponownie. Po drugie, useFetcher z parametrem key pozwala na rewalidację konkretnych fetcher loaderów. Po trzecie, możesz wywołać rewalidację ręcznie przez useRevalidator() hook.
Ważne przypadki brzegowe: jeśli action zwraca redirect, rewalidacja następuje na nowej stronie (docelowej przekierowania). Jeśli action rzuca błąd, rewalidacja się nie wykonuje - UI pozostaje w poprzednim stanie. Rewalidacja jest optymalizowana - Remix nie wykonuje loadera, jeśli jego URL/params się nie zmieniły i nie było action, chyba że wymusisz to przez shouldRevalidate lub useRevalidator.
Przykład kodu:
// app/routes/projects.$projectId.tsx
import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useFetcher, Form } from "@remix-run/react";
// Loader - ładuje dane projektu
export async function loader({ params }: LoaderFunctionArgs) {
const project = await db.project.findUnique({
where: { id: params.projectId },
include: {
tasks: true,
members: true
}
});
if (!project) {
throw new Response("Not Found", { status: 404 });
}
return json({
project,
timestamp: new Date().toISOString() // Pokaże, kiedy dane zostały załadowane
});
}
// Action - mutuje dane
export async function action({ params, request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "updateName") {
const name = formData.get("name");
await db.project.update({
where: { id: params.projectId },
data: { name: String(name) }
});
// Po zakończeniu action, Remix automatycznie:
// 1. Wywołuje loader powyżej
// 2. Aktualizuje dane w useLoaderData
// 3. Re-renderuje komponent
return json({ success: true });
}
if (intent === "addTask") {
const title = formData.get("title");
await db.task.create({
data: {
title: String(title),
projectId: params.projectId
}
});
// Loader zostanie wywołany ponownie
// project.tasks będzie zawierało nowe zadanie
return json({ success: true });
}
if (intent === "delete") {
await db.project.delete({
where: { id: params.projectId }
});
// Po usunięciu przekierowujemy
// Rewalidacja nastąpi na stronie /projects
return redirect("/projects");
}
throw new Response("Bad Request", { status: 400 });
}
export default function ProjectDetail() {
const { project, timestamp } = useLoaderData<typeof loader>();
const fetcher = useFetcher();
return (
<div>
<h1>{project.name}</h1>
<p>Dane załadowane: {new Date(timestamp).toLocaleTimeString()}</p>
{/* Form 1: Aktualizacja nazwy */}
<Form method="post">
<input type="hidden" name="intent" value="updateName" />
<input
type="text"
name="name"
defaultValue={project.name}
required
/>
<button type="submit">
Zmień nazwę
</button>
</Form>
{/* Form 2: Dodawanie zadania */}
<Form method="post">
<input type="hidden" name="intent" value="addTask" />
<input
type="text"
name="title"
placeholder="Nowe zadanie"
required
/>
<button type="submit">
Dodaj zadanie
</button>
</Form>
{/* Lista zadań - automatycznie aktualizuje się po dodaniu */}
<ul>
{project.tasks.map(task => (
<li key={task.id}>{task.title}</li>
))}
</ul>
{/* useFetcher - nie powoduje nawigacji, ale wciąż rewaliduje */}
<fetcher.Form method="post">
<input type="hidden" name="intent" value="addTask" />
<input type="text" name="title" placeholder="Przez fetcher" />
<button type="submit">
Dodaj (bez nawigacji)
</button>
</fetcher.Form>
{/* Przycisk usuwania - przekierowuje po akcji */}
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<button type="submit" className="danger">
Usuń projekt
</button>
</Form>
<h2>Członkowie ({project.members.length})</h2>
{/* ... */}
</div>
);
}
Przykład z kontrolą rewalidacji:
// app/routes/dashboard.tsx
import { type ShouldRevalidateFunction } from "@remix-run/node";
export const shouldRevalidate: ShouldRevalidateFunction = ({
actionResult,
currentParams,
currentUrl,
nextParams,
nextUrl,
defaultShouldRevalidate,
formAction,
formMethod
}) => {
// Nie rewaliduj dashboard stats jeśli akcja była w innej trasie
// (np. dodawanie zadania w project detail nie musi przeładowywać dashboard)
if (formAction && !formAction.startsWith('/dashboard')) {
return false;
}
// Rewaliduj tylko przy POST/DELETE/PUT
if (formMethod && ['GET', 'HEAD'].includes(formMethod)) {
return false;
}
// Możemy sprawdzić actionResult i zdecydować
if (actionResult && 'skipRevalidation' in actionResult) {
return false;
}
return defaultShouldRevalidate;
};
export async function loader({ request }: LoaderFunctionArgs) {
// Ten loader wykonuje się tylko gdy shouldRevalidate zwróci true
const stats = await fetchExpensiveDashboardStats();
return json({ stats });
}
Przykład z ręczną rewalidacją:
// app/routes/live-dashboard.tsx
import { useRevalidator } from "@remix-run/react";
import { useEffect } from "react";
export async function loader() {
const liveData = await fetchLiveData();
return json({
data: liveData,
timestamp: Date.now()
});
}
export default function LiveDashboard() {
const { data, timestamp } = useLoaderData<typeof loader>();
const revalidator = useRevalidator();
// Automatyczna rewalidacja co 30 sekund
useEffect(() => {
const interval = setInterval(() => {
// Ręcznie wywołaj rewalidację
if (revalidator.state === "idle") {
revalidator.revalidate();
}
}, 30000);
return () => clearInterval(interval);
}, [revalidator]);
return (
<div>
<h1>Live Dashboard</h1>
<p>
Status: {revalidator.state === "loading" ? "Aktualizowanie..." : "Aktualne"}
</p>
<p>Ostatnia aktualizacja: {new Date(timestamp).toLocaleTimeString()}</p>
<button
onClick={() => revalidator.revalidate()}
disabled={revalidator.state === "loading"}
>
Odśwież teraz
</button>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
Przykład z rewalidacją w zagnieżdżonych trasach:
// app/routes/projects.tsx - parent route
export async function loader() {
const projects = await db.project.findMany();
return json({ projects });
}
// app/routes/projects.$projectId.tsx - child route
export async function action({ params, request }: ActionFunctionArgs) {
const formData = await request.formData();
await db.project.update({
where: { id: params.projectId },
data: { name: String(formData.get("name")) }
});
// Po tym action:
// 1. projects.$projectId loader się rewaliduje (dane projektu)
// 2. projects loader się rewaliduje (lista wszystkich projektów)
// Oba równolegle!
return json({ success: true });
}
Diagram:
sequenceDiagram
participant User
participant Form
participant Remix
participant Action
participant Loaders
participant UI
User->>Form: Submit formularza
Form->>Remix: POST /projects/123
Remix->>Action: Wywołaj action({ params, request })
Action->>Action: Mutacja danych w bazie
Action-->>Remix: return json({ success: true })
Note over Remix: Rozpocznij rewalidację
par Równoległa rewalidacja wszystkich loaderów
Remix->>Loaders: root.loader()
Remix->>Loaders: projects.loader()
Remix->>Loaders: projects.$projectId.loader()
end
Loaders-->>Remix: Nowe dane ze wszystkich loaderów
Remix->>UI: Aktualizuj useLoaderData()
UI->>UI: Re-render z nowymi danymi
UI-->>User: Zaktualizowany interfejs
Note over User,UI: Alternative: Action z redirect
User->>Form: Submit (delete project)
Form->>Remix: POST /projects/123
Remix->>Action: action()
Action->>Action: Usuń projekt
Action-->>Remix: redirect("/projects")
Remix->>Loaders: Rewalidacja na /projects
Loaders-->>Remix: Nowe dane
Remix-->>User: Przekierowanie + nowe dane
Materiały:
18. Czym jest hook useFetcher i kiedy go używać?
Odpowiedź w 30 sekund:
useFetcher to hook pozwalający na komunikację z loaderami i actions bez powodowania nawigacji. Używamy go do operacji w tle: dodawania do koszyka, like'owania, zapisywania draftu, ładowania danych modalnego okna. Każdy fetcher ma własny stan (idle/loading/submitting) i może wykonywać równoległe operacje niezależnie od głównej nawigacji.
Odpowiedź w 2 minuty:
useFetcher to potężny hook umożliwiający interakcję z backend bez wpływu na URL i historię przeglądarki. Podczas gdy zwykły <Form> powoduje nawigację i aktualizuje URL, fetcher.Form wykonuje akcję "w tle" - użytkownik pozostaje na tej samej stronie, URL się nie zmienia, ale akcja jest wykonana i dane rewalidowane.
Każdy fetcher to niezależna maszyna stanów z własnymi fazami: idle (bezczynny), submitting (wysyła dane), loading (ładuje dane po action). Może równocześnie istnieć wiele fetcherów - każdy z unikalnym kluczem, każdy niezależnie wykonujący operacje. Fetcher automatycznie wyzwala rewalidację loaderów po zakończeniu action, więc UI się aktualizuje, mimo że nie było nawigacji.
Kluczowe przypadki użycia to: interaktywne UI (like, favorite, follow bez przeładowania), formularze w komponentach (newsletter signup w footer, komentarze), operacje CRUD w listach (usuń, edytuj inline), lazy loading danych (wczytaj więcej, infinite scroll), optymistic UI (natychmiastowa aktualizacja przed odpowiedzią serwera), równoległe operacje (wiele zapisów jednocześnie).
API fetchera oferuje: fetcher.Form (komponent formualarza), fetcher.submit(data, options) (programowy submit), fetcher.load(url) (załaduj dane z loadera), fetcher.data (dane zwrócone przez loader/action), fetcher.state (stan fetcher), fetcher.formData (dane wysyłanego formularza, dla optimistic UI). Możesz też używać useFetchers() aby dostać listę wszystkich aktywnych fetcherów w aplikacji - przydatne do globalnych loading indicatorów.
Przykład kodu:
// app/routes/posts.$postId.tsx
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useFetcher } from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.post.findUnique({
where: { id: params.postId },
include: {
likes: true,
_count: { select: { likes: true } }
}
});
return json({ post });
}
export async function action({ request, params }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
const userId = await getUserId(request);
if (intent === "like") {
const existingLike = await db.like.findUnique({
where: {
userId_postId: {
userId,
postId: params.postId!
}
}
});
if (existingLike) {
// Unlike
await db.like.delete({
where: { id: existingLike.id }
});
return json({ liked: false });
} else {
// Like
await db.like.create({
data: {
userId,
postId: params.postId!
}
});
return json({ liked: true });
}
}
if (intent === "save") {
const content = formData.get("content");
await db.post.update({
where: { id: params.postId },
data: { content: String(content) }
});
return json({ success: true });
}
throw new Response("Bad Request", { status: 400 });
}
export default function PostDetail() {
const { post } = useLoaderData<typeof loader>();
const likeFetcher = useFetcher();
const saveFetcher = useFetcher();
// Optimistic UI - pokaż stan przed otrzymaniem odpowiedzi
const isLiked = likeFetcher.formData
? likeFetcher.formData.get("intent") === "like"
: post.likes.some(like => like.userId === currentUserId);
const likeCount = likeFetcher.formData
? post._count.likes + (isLiked ? 1 : -1)
: post._count.likes;
// Sprawdź czy fetcher jest aktywny
const isLiking = likeFetcher.state !== "idle";
const isSaving = saveFetcher.state === "submitting";
return (
<article>
<h1>{post.title}</h1>
{/* Like button - używa fetcher.Form (bez nawigacji) */}
<likeFetcher.Form method="post">
<input type="hidden" name="intent" value="like" />
<button
type="submit"
disabled={isLiking}
className={isLiked ? "liked" : ""}
>
{isLiked ? "❤️" : "🤍"} {likeCount}
{isLiking && " (ładowanie...)"}
</button>
</likeFetcher.Form>
{/* Inline editing - zapisuje bez nawigacji */}
<saveFetcher.Form method="post">
<input type="hidden" name="intent" value="save" />
<textarea
name="content"
defaultValue={post.content}
/>
<button type="submit" disabled={isSaving}>
{isSaving ? "Zapisywanie..." : "Zapisz"}
</button>
{saveFetcher.data?.success && (
<span className="success">✓ Zapisano</span>
)}
</saveFetcher.Form>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
Przykład z programowym submit i load:
// app/routes/products.tsx
import { useFetcher } from "@remix-run/react";
import { useEffect } from "react";
export default function Products() {
const addToCartFetcher = useFetcher();
const quickViewFetcher = useFetcher<typeof import("./api.product-details").loader>();
const handleAddToCart = (productId: string) => {
// Programowy submit - nie potrzebujemy formularza
addToCartFetcher.submit(
{ productId, quantity: "1", intent: "add-to-cart" },
{ method: "post", action: "/cart" }
);
};
const handleQuickView = (productId: string) => {
// Załaduj dane z innej trasy bez nawigacji
quickViewFetcher.load(`/api/product-details?productId=${productId}`);
};
// Pokaż toast gdy produkt dodany do koszyka
useEffect(() => {
if (addToCartFetcher.state === "idle" && addToCartFetcher.data) {
showToast("Dodano do koszyka!");
}
}, [addToCartFetcher.state, addToCartFetcher.data]);
return (
<div>
<div className="products-grid">
{products.map(product => (
<div key={product.id} className="product-card">
<h3>{product.name}</h3>
<p>{product.price} PLN</p>
<button
onClick={() => handleAddToCart(product.id)}
disabled={addToCartFetcher.state !== "idle"}
>
{addToCartFetcher.state === "submitting"
? "Dodawanie..."
: "Do koszyka"}
</button>
<button onClick={() => handleQuickView(product.id)}>
Szybki podgląd
</button>
</div>
))}
</div>
{/* Modal z danymi z fetchera */}
{quickViewFetcher.state !== "idle" && (
<Modal>
{quickViewFetcher.state === "loading" ? (
<div>Ładowanie...</div>
) : quickViewFetcher.data ? (
<ProductDetails product={quickViewFetcher.data.product} />
) : null}
</Modal>
)}
</div>
);
}
Przykład z wieloma równoległymi operacjami:
// app/routes/todo-list.tsx
import { useFetcher, useFetchers } from "@remix-run/react";
export default function TodoList() {
const createFetcher = useFetcher();
const allFetchers = useFetchers();
// Sprawdź czy jakiekolwiek zadanie jest w trakcie aktualizacji
const hasActiveFetchers = allFetchers.some(
f => f.state === "submitting" || f.state === "loading"
);
return (
<div>
{/* Globalny indicator */}
{hasActiveFetchers && (
<div className="global-loading">Synchronizacja...</div>
)}
{/* Formularz tworzenia */}
<createFetcher.Form method="post">
<input type="hidden" name="intent" value="create" />
<input type="text" name="title" placeholder="Nowe zadanie" />
<button type="submit">Dodaj</button>
</createFetcher.Form>
{/* Lista zadań - każde z własnym fetcherem */}
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
</div>
);
}
function TodoItem({ todo }: { todo: Todo }) {
const fetcher = useFetcher();
// Optimistic UI - pokaż stan przed odpowiedzią
const isCompleted = fetcher.formData
? fetcher.formData.get("completed") === "true"
: todo.completed;
const isDeleting = fetcher.formData?.get("intent") === "delete";
if (isDeleting && fetcher.state !== "idle") {
// Optimistically ukryj element podczas usuwania
return null;
}
return (
<li style={{ opacity: fetcher.state !== "idle" ? 0.5 : 1 }}>
<fetcher.Form method="post">
<input type="hidden" name="todoId" value={todo.id} />
<input type="hidden" name="intent" value="toggle" />
<input
type="hidden"
name="completed"
value={String(!isCompleted)}
/>
<button
type="submit"
className={isCompleted ? "completed" : ""}
>
{isCompleted ? "✓" : "○"} {todo.title}
</button>
</fetcher.Form>
<fetcher.Form method="post" style={{ display: "inline" }}>
<input type="hidden" name="todoId" value={todo.id} />
<input type="hidden" name="intent" value="delete" />
<button type="submit" className="delete">
🗑
</button>
</fetcher.Form>
</li>
);
}
Przykład z fetcher.key dla współdzielenia stanu:
// Użyj tego samego klucza aby współdzielić stan fetcher między komponentami
function AddToCartButton({ productId }: { productId: string }) {
const fetcher = useFetcher({ key: `add-to-cart-${productId}` });
return (
<fetcher.Form method="post" action="/cart">
<input type="hidden" name="productId" value={productId} />
<button type="submit">
{fetcher.state === "submitting" ? "Dodawanie..." : "Do koszyka"}
</button>
</fetcher.Form>
);
}
// Gdzieś indziej w aplikacji - ten sam stan
function CartCount({ productId }: { productId: string }) {
const fetcher = useFetcher({ key: `add-to-cart-${productId}` });
if (fetcher.state !== "idle") {
return <div className="adding">+1</div>;
}
return null;
}
Diagram:
flowchart TD
A[Użytkownik klika 'Like'] --> B[useFetcher.Form submit]
B --> C{Stan fetchera}
C -->|submitting| D[Pokazuje 'Ładowanie...']
D --> E[POST /posts/123 - action]
E --> F[Aktualizacja bazy danych]
F --> G[return json - success]
G --> H[Automatyczna rewalidacja loaderów]
H --> I{Stan fetchera}
I -->|loading| J[Pobieranie nowych danych]
J --> K[Aktualizacja useLoaderData]
K --> L{Stan fetchera}
L -->|idle| M[UI zaktualizowane - nowy stan like]
N[Zwykły Form submit] --> O[Nawigacja - zmiana URL]
O --> P[Nowy loader + action]
B -.Bez nawigacji.-> M
style B fill:#e1f5ff
style H fill:#fff4e1
style M fill:#e8f5e9
style O fill:#ffebee
Materiały:
Action i Mutacje Danych
19. Czym jest funkcja action i jak obsługiwać formularze w Remix?
Odpowiedź w 30 sekund:
Funkcja action w Remix to funkcja serwerowa, która obsługuje mutacje danych (POST, PUT, DELETE, PATCH). Jest wywoływana przed loader po przesłaniu formularza i pozwala na bezpieczne przetwarzanie danych po stronie serwera z automatyczną rewalidacją.
Odpowiedź w 2 minuty:
Funkcja action jest fundamentalnym elementem obsługi formularzy w Remix, działającym wyłącznie po stronie serwera. Jest wywoływana dla żądań nie-GET (POST, PUT, DELETE, PATCH) i stanowi centralny punkt przetwarzania mutacji danych. Po wykonaniu akcji, Remix automatycznie wywołuje wszystkie loadery na aktywnych trasach, co zapewnia synchronizację UI z aktualnym stanem danych bez dodatkowego kodu.
Obsługa formularzy w Remix różni się od tradycyjnych SPA - nie wymaga useState, useEffect czy ręcznego zarządzania stanem. Zamiast tego wykorzystuje natywne formularze HTML wzbogacone o progresywne ulepszenia. Proces wygląda następująco: użytkownik przesyła formularz → Remix wywołuje action → akcja przetwarza dane i zwraca odpowiedź → Remix rewaliduje wszystkie loadery → UI aktualizuje się automatycznie.
Action otrzymuje obiekt Request z dostępem do request.formData(), co umożliwia wyciąganie wartości pól formularza. Może zwracać różne typy odpowiedzi: dane JSON (przez json()), przekierowania (przez redirect()), lub błędy walidacji. Kluczową zaletą jest automatyczne zarządzanie stanem ładowania przez hooki useNavigation, co eliminuje potrzebę ręcznego trackowania pending states.
Remix wspiera również akcje zagnieżdżone - gdy przesyłasz formularz w zagnieżdżonej trasie, wywoływana jest action tej konkretnej trasy, a następnie rewalidowane są wszystkie loadery w hierarchii. To zapewnia spójność danych w całej aplikacji bez dodatkowej konfiguracji.
Przykład kodu:
// app/routes/posts.new.tsx
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { createPost } from "~/models/post.server";
// Funkcja action obsługująca POST z formularza
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get("title");
const content = formData.get("content");
// Walidacja po stronie serwera
const errors = {};
if (!title || title.length < 3) {
errors.title = "Tytuł musi mieć minimum 3 znaki";
}
if (!content || content.length < 10) {
errors.content = "Treść musi mieć minimum 10 znaków";
}
// Zwróć błędy jeśli walidacja nie przeszła
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 400 });
}
// Utwórz post w bazie danych
const post = await createPost({ title, content });
// Przekieruj do nowo utworzonego posta
return redirect(`/posts/${post.id}`);
}
export default function NewPost() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<div>
<label htmlFor="title">Tytuł:</label>
<input
type="text"
id="title"
name="title"
required
/>
{actionData?.errors?.title && (
<p className="error">{actionData.errors.title}</p>
)}
</div>
<div>
<label htmlFor="content">Treść:</label>
<textarea
id="content"
name="content"
required
/>
{actionData?.errors?.content && (
<p className="error">{actionData.errors.content}</p>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Tworzenie..." : "Utwórz Post"}
</button>
</Form>
);
}
Diagram:
sequenceDiagram
participant User as Użytkownik
participant Form as Formularz Remix
participant Action as action()
participant DB as Baza Danych
participant Loader as loader()
participant UI as UI
User->>Form: Wypełnia i wysyła formularz
Form->>Action: POST request z FormData
Action->>Action: Walidacja danych
alt Błędy walidacji
Action->>UI: Zwraca błędy (json)
UI->>User: Wyświetla błędy
else Walidacja OK
Action->>DB: Zapisuje dane
DB->>Action: Potwierdzenie
Action->>Form: redirect() lub json()
Form->>Loader: Automatyczna rewalidacja
Loader->>DB: Pobiera świeże dane
DB->>Loader: Zwraca dane
Loader->>UI: Aktualizuje interfejs
UI->>User: Wyświetla sukces
end
Materiały:
20. Jak działa komponent Form w porównaniu do natywnego formularza HTML?
Odpowiedź w 30 sekund:
Komponent Form z Remix rozszerza natywny HTML <form> o progresywne ulepszenia - przechwytuje submit, wysyła dane przez fetch zamiast pełnego przeładowania strony, i automatycznie zarządza stanem nawigacji. Działa jak zwykły formularz gdy JavaScript jest wyłączony.
Odpowiedź w 2 minuty:
Komponent Form w Remix jest przykładem progresywnego ulepszania (progressive enhancement) - fundamentalnej filozofii frameworka. Renderuje się jako standardowy HTML <form>, więc działa nawet bez JavaScript, ale gdy JavaScript jest dostępny, dodaje znaczące ulepszenia UX.
Kluczowe różnice: natywny formularz HTML przy submit powoduje pełne przeładowanie strony i utratę stanu aplikacji. Form z Remix przechwytuje event submit, wysyła dane przez fetch, i aktualizuje tylko niezbędne części UI. Zachowuje scroll position, focus stanu, i inne elementy kontekstu aplikacji. Automatycznie ustawia odpowiednie nagłówki HTTP i obsługuje różne metody (GET, POST, PUT, DELETE, PATCH).
Remix Form integruje się z systemem nawigacji - hook useNavigation pozwala śledzić stan submitu (idle, submitting, loading) i wyświetlać loading states bez dodatkowego kodu. Automatycznie zarządza formData podczas submitu, co umożliwia implementację optimistic UI. Po zakończeniu akcji automatycznie rewaliduje wszystkie loadery, zapewniając synchronizację danych.
Ważną cechą jest możliwość kontroli zachowania przez prop reloadDocument - gdy ustawiony na true, Form zachowuje się jak natywny formularz z pełnym przeładowaniem. To przydatne dla operacji wymagających pełnego resetu aplikacji (np. logout). Dodatkowo, Form wspiera prop replace, który kontroluje czy submit dodaje nowy wpis do historii przeglądarki czy zastępuje obecny.
Przykład kodu:
// app/routes/tasks.tsx
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useNavigation, useFetcher } from "@remix-run/react";
import { getTasks, createTask, deleteTask } from "~/models/task.server";
// Loader pobiera listę zadań
export async function loader({ request }: LoaderFunctionArgs) {
const tasks = await getTasks();
return json({ tasks });
}
// Action obsługuje tworzenie i usuwanie zadań
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "create") {
const title = formData.get("title");
await createTask({ title });
} else if (intent === "delete") {
const taskId = formData.get("taskId");
await deleteTask(taskId);
}
return json({ success: true });
}
export default function Tasks() {
const { tasks } = useLoaderData<typeof loader>();
const navigation = useNavigation();
// Sprawdź czy formularz jest w trakcie submitu
const isCreating = navigation.state === "submitting" &&
navigation.formData?.get("intent") === "create";
return (
<div>
<h1>Moje Zadania</h1>
{/* Remix Form - z progressive enhancement */}
<Form method="post">
<input type="hidden" name="intent" value="create" />
<input
type="text"
name="title"
placeholder="Nowe zadanie..."
required
/>
<button type="submit" disabled={isCreating}>
{isCreating ? "Dodawanie..." : "Dodaj"}
</button>
</Form>
{/* Lista zadań z możliwością usunięcia */}
<ul>
{tasks.map(task => (
<li key={task.id}>
{task.title}
<Form method="post" style={{ display: "inline" }}>
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="taskId" value={task.id} />
<button type="submit">Usuń</button>
</Form>
</li>
))}
</ul>
{/* Przykład z reloadDocument - wymusza pełne przeładowanie */}
<Form method="post" action="/logout" reloadDocument>
<button type="submit">Wyloguj (pełne przeładowanie)</button>
</Form>
{/* Natywny HTML form dla porównania */}
<form method="post" action="/native-submit">
{/* To spowoduje pełne przeładowanie strony */}
<input type="text" name="query" />
<button type="submit">Szukaj (natywnie)</button>
</form>
</div>
);
}
// Przykład z useFormAction dla search form (metoda GET)
function SearchForm() {
const navigation = useNavigation();
const isSearching = navigation.state === "loading";
return (
// GET forms aktualizują URL params bez przeładowania strony
<Form method="get" action="/search">
<input
type="search"
name="q"
placeholder="Szukaj..."
defaultValue={navigation.location?.search}
/>
<button type="submit">
{isSearching ? "Szukanie..." : "Szukaj"}
</button>
</Form>
);
}
Diagram:
flowchart TD
A[Użytkownik submituje formularz] --> B{JavaScript włączony?}
B -->|NIE| C[Natywny HTML Form]
C --> D[Pełne przeładowanie strony]
D --> E[Utrata stanu aplikacji]
D --> F[Wywołanie action na serwerze]
F --> G[Zwrócenie nowego HTML]
B -->|TAK| H[Remix Form Component]
H --> I[Przechwycenie submit event]
I --> J[Wysłanie przez fetch API]
J --> K[Wywołanie action na serwerze]
K --> L[Automatyczna rewalidacja loaderów]
L --> M[Aktualizacja tylko zmienionych danych]
M --> N[Zachowanie stanu aplikacji]
M --> O[Zachowanie scroll position]
M --> P[Zachowanie focus state]
style H fill:#4CAF50
style C fill:#FF9800
Materiały:
Odpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziObsługa Błędów
Odpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziSesje i Autentykacja
Odpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziStylowanie i Assets
Odpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOptymalizacja i Wydajność
Odpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziDeployment i Konfiguracja
Odpowiedź dostępna w pełnej wersji. Odblokuj 20 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedzi