To jest darmowy podgląd

To jest próbka 15 pytań z naszej pełnej kolekcji 40 pytań rekrutacyjnych. Uzyskaj pełny dostęp do wszystkich pytań ze szczegółowymi odpowiedziami i przykładami kodu.

Kup pełny dostęp

Remix - Sekcja 1: 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:

↑ Powrót na górę

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:

↑ Powrót na górę

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:

↑ Powrót na górę

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:

↑ Powrót na górę

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:

↑ Powrót na górę

Remix - Sekcja 2: Routing

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>&copy; 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

↑ Powrót na górę

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

↑ Powrót na górę

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

↑ Powrót na górę

Remix - Sekcja 4: Action i Mutacje Danych

21. Jak obsługiwać walidację formularzy po stronie serwera?

Odpowiedź w 30 sekund: Walidacja w Remix odbywa się w funkcji action po stronie serwera. Sprawdzasz dane z formData, zwracasz błędy przez json() z odpowiednim statusem HTTP, a następnie wyświetlasz je w komponencie używając useActionData(). To zapewnia bezpieczeństwo i działa nawet bez JavaScript.

Odpowiedź w 2 minuty: Walidacja po stronie serwera w Remix jest obowiązkowa i stanowi pierwszą linię obrony przed nieprawidłowymi danymi. Nigdy nie ufaj danym z przeglądarki - walidacja po stronie klienta to tylko udogodnienie UX, nie zabezpieczenie. W Remix cała logika walidacji znajduje się w funkcji action, co centralizuje reguły biznesowe i zapewnia spójność.

Proces walidacji wygląda następująco: odbierasz formData z requestu, wyciągasz wartości pól, sprawdzasz każde pole według reguł biznesowych, i jeśli znajdziesz błędy, zwracasz je jako obiekt JSON z kodem statusu 400 lub 422. Błędy powinny być strukturalne - zazwyczaj obiekt gdzie klucze to nazwy pól, a wartości to komunikaty błędów. To ułatwia mapowanie błędów do konkretnych pól w UI.

Zaleca się używanie bibliotek walidacyjnych jak Zod, Yup, czy superstruct zamiast ręcznego pisania walidacji. Dają one type safety, kompozycję reguł, i automatyczne generowanie komunikatów błędów. Zod szczególnie dobrze integruje się z TypeScript w Remix - możesz zdefiniować schema raz i używać jej zarówno do walidacji jak i type inference.

Ważne jest zwracanie odpowiednich kodów statusu HTTP: 400 dla błędów walidacji danych wejściowych, 422 dla błędów semantycznych (np. duplikat email), 500 dla błędów serwera. Remix automatycznie obsługuje te kody i wywołuje Error Boundary dla 4xx i 5xx gdy odpowiednie. Możesz też zwrócić sukces z kodem 200 i danymi (np. ID nowego rekordu) lub przekierowanie 302/303.

Przykład kodu:

// app/routes/register.tsx
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { z } from "zod";
import { createUser, getUserByEmail } from "~/models/user.server";
import { createUserSession } from "~/session.server";

// Schema walidacji z Zod
const RegisterSchema = z.object({
  email: z
    .string()
    .min(1, "Email jest wymagany")
    .email("Nieprawidłowy format email"),
  password: z
    .string()
    .min(8, "Hasło musi mieć minimum 8 znaków")
    .regex(/[A-Z]/, "Hasło musi zawierać wielką literę")
    .regex(/[0-9]/, "Hasło musi zawierać cyfrę"),
  confirmPassword: z.string(),
  username: z
    .string()
    .min(3, "Nazwa użytkownika musi mieć minimum 3 znaki")
    .max(20, "Nazwa użytkownika może mieć maksymalnie 20 znaków")
    .regex(/^[a-zA-Z0-9_]+$/, "Tylko litery, cyfry i podkreślnik"),
  terms: z
    .literal("on", {
      errorMap: () => ({ message: "Musisz zaakceptować regulamin" })
    })
}).refine((data) => data.password === data.confirmPassword, {
  message: "Hasła muszą być identyczne",
  path: ["confirmPassword"]
});

type ActionData = {
  errors?: {
    email?: string;
    password?: string;
    confirmPassword?: string;
    username?: string;
    terms?: string;
    form?: string;
  };
};

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();

  // Konwersja FormData do obiektu
  const rawData = {
    email: formData.get("email"),
    password: formData.get("password"),
    confirmPassword: formData.get("confirmPassword"),
    username: formData.get("username"),
    terms: formData.get("terms")
  };

  // Walidacja z Zod
  const result = RegisterSchema.safeParse(rawData);

  if (!result.success) {
    // Przekształć błędy Zod do formatu dla UI
    const errors: ActionData["errors"] = {};
    result.error.issues.forEach((issue) => {
      const path = issue.path[0] as string;
      errors[path] = issue.message;
    });

    return json<ActionData>(
      { errors },
      { status: 400 }
    );
  }

  const { email, password, username } = result.data;

  // Sprawdź czy użytkownik już istnieje
  const existingUser = await getUserByEmail(email);
  if (existingUser) {
    return json<ActionData>(
      {
        errors: {
          email: "Użytkownik z tym adresem email już istnieje"
        }
      },
      { status: 422 }
    );
  }

  // Utwórz użytkownika
  try {
    const user = await createUser({ email, password, username });

    // Utwórz sesję i przekieruj
    return createUserSession({
      request,
      userId: user.id,
      redirectTo: "/dashboard"
    });
  } catch (error) {
    return json<ActionData>(
      {
        errors: {
          form: "Wystąpił błąd podczas tworzenia konta. Spróbuj ponownie."
        }
      },
      { status: 500 }
    );
  }
}

export default function Register() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <div>
      <h1>Rejestracja</h1>

      <Form method="post">
        {/* Globalny błąd formularza */}
        {actionData?.errors?.form && (
          <div className="alert alert-error">
            {actionData.errors.form}
          </div>
        )}

        <div className="form-group">
          <label htmlFor="email">Email:</label>
          <input
            type="email"
            id="email"
            name="email"
            required
            aria-invalid={actionData?.errors?.email ? true : undefined}
            aria-describedby={actionData?.errors?.email ? "email-error" : undefined}
          />
          {actionData?.errors?.email && (
            <p className="error" id="email-error">
              {actionData.errors.email}
            </p>
          )}
        </div>

        <div className="form-group">
          <label htmlFor="username">Nazwa użytkownika:</label>
          <input
            type="text"
            id="username"
            name="username"
            required
            aria-invalid={actionData?.errors?.username ? true : undefined}
            aria-describedby={actionData?.errors?.username ? "username-error" : undefined}
          />
          {actionData?.errors?.username && (
            <p className="error" id="username-error">
              {actionData.errors.username}
            </p>
          )}
        </div>

        <div className="form-group">
          <label htmlFor="password">Hasło:</label>
          <input
            type="password"
            id="password"
            name="password"
            required
            aria-invalid={actionData?.errors?.password ? true : undefined}
            aria-describedby={actionData?.errors?.password ? "password-error" : undefined}
          />
          {actionData?.errors?.password && (
            <p className="error" id="password-error">
              {actionData.errors.password}
            </p>
          )}
        </div>

        <div className="form-group">
          <label htmlFor="confirmPassword">Potwierdź hasło:</label>
          <input
            type="password"
            id="confirmPassword"
            name="confirmPassword"
            required
            aria-invalid={actionData?.errors?.confirmPassword ? true : undefined}
            aria-describedby={actionData?.errors?.confirmPassword ? "confirm-error" : undefined}
          />
          {actionData?.errors?.confirmPassword && (
            <p className="error" id="confirm-error">
              {actionData.errors.confirmPassword}
            </p>
          )}
        </div>

        <div className="form-group">
          <label>
            <input
              type="checkbox"
              name="terms"
              aria-invalid={actionData?.errors?.terms ? true : undefined}
              aria-describedby={actionData?.errors?.terms ? "terms-error" : undefined}
            />
            Akceptuję regulamin
          </label>
          {actionData?.errors?.terms && (
            <p className="error" id="terms-error">
              {actionData.errors.terms}
            </p>
          )}
        </div>

        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? "Rejestrowanie..." : "Zarejestruj się"}
        </button>
      </Form>
    </div>
  );
}

Diagram:

flowchart TD
    A[Użytkownik wysyła formularz] --> B[action otrzymuje FormData]
    B --> C[Parsowanie danych]
    C --> D[Walidacja Schema Zod]

    D --> E{Błędy walidacji?}
    E -->|TAK| F[Zwróć json z błędami + status 400]
    F --> G[useActionData w komponencie]
    G --> H[Wyświetl błędy przy polach]
    H --> I[Użytkownik poprawia dane]
    I --> A

    E -->|NIE| J[Walidacja biznesowa]
    J --> K{Email już istnieje?}
    K -->|TAK| L[Zwróć błąd + status 422]
    L --> G

    K -->|NIE| M[Zapis do bazy danych]
    M --> N{Sukces?}
    N -->|NIE| O[Zwróć błąd serwera + status 500]
    O --> G

    N -->|TAK| P[Utwórz sesję]
    P --> Q[redirect do /dashboard]

    style F fill:#FF5722
    style L fill:#FF9800
    style O fill:#F44336
    style Q fill:#4CAF50

Materiały:

↑ Powrót na górę

Remix - Sekcja 6: Sesje i Autentykacja

31. Jak chronić trasy wymagające uwierzytelnienia?

Odpowiedź w 30 sekund: Trasy chronimy w loaderze wywołując funkcję requireUserId lub requireUser, która sprawdza sesję i albo zwraca dane użytkownika, albo rzuca redirect do strony logowania. Można też używać middleware patterns lub utility functions, które weryfikują uprawnienia i role użytkownika przed udostępnieniem danych.

Odpowiedź w 2 minuty: Ochrona tras w Remix odbywa się głównie w loaderach, gdzie przed zwróceniem jakichkolwiek danych weryfikujemy tożsamość użytkownika. Najprostszy sposób to wywołanie requireUserId(request), która sprawdza sesję i albo zwraca user ID, albo rzuca redirect (throw redirect("/login")). Rzucenie redirecta w loaderze przerywa wykonanie i natychmiast przekierowuje użytkownika.

Dla bardziej zaawansowanych przypadków można implementować role-based access control (RBAC). Tworzymy funkcje typu requireAdmin, requireRole, które nie tylko sprawdzają czy użytkownik jest zalogowany, ale też czy ma odpowiednie uprawnienia. Jeśli nie ma, można przekierować do strony błędu 403 lub do dashboard.

Warto też przekazywać redirectTo parameter, aby po zalogowaniu użytkownik wrócił do oryginalnie żądanej strony. W nested routes ochrona w parent route automatycznie chroni wszystkie child routes, co pozwala na DRY (Don't Repeat Yourself) pattern.

Actions również wymagają ochrony - użytkownik może wysłać POST request bez dostępu do UI, więc zawsze weryfikuj sesję w action przed wykonaniem jakiejkolwiek operacji mutującej dane. Kombinacja loader + action protection zapewnia pełne zabezpieczenie route.

Przykład kodu:

// app/utils/auth.server.ts
import { redirect } from "@remix-run/node";
import { db } from "~/db.server";
import { getUserId } from "./session.server";

// Podstawowa ochrona - wymaga zalogowania
export async function requireUserId(
  request: Request,
  redirectTo: string = new URL(request.url).pathname
) {
  const userId = await getUserId(request);
  if (!userId) {
    const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
    throw redirect(`/login?${searchParams}`);
  }
  return userId;
}

// Ochrona z pobraniem pełnych danych użytkownika
export async function requireUser(request: Request) {
  const userId = await requireUserId(request);

  const user = await db.user.findUnique({
    where: { id: userId },
    select: { id: true, email: true, role: true, name: true },
  });

  if (!user) {
    throw logout(request);
  }

  return user;
}

// Ochrona wymagająca roli administratora
export async function requireAdmin(request: Request) {
  const user = await requireUser(request);

  if (user.role !== "ADMIN") {
    throw new Response("Brak uprawnień", { status: 403 });
  }

  return user;
}

// Ochrona z weryfikacją konkretnej roli
export async function requireRole(request: Request, role: string) {
  const user = await requireUser(request);

  if (user.role !== role) {
    throw new Response("Brak uprawnień", { status: 403 });
  }

  return user;
}

// Ochrona z weryfikacją właściciela zasobu
export async function requireResourceOwner(
  request: Request,
  resourceId: string,
  resourceType: "post" | "comment"
) {
  const user = await requireUser(request);

  // Sprawdź czy użytkownik jest właścicielem zasobu
  const resource = await db[resourceType].findUnique({
    where: { id: resourceId },
    select: { userId: true },
  });

  if (!resource || resource.userId !== user.id) {
    throw new Response("Brak dostępu do zasobu", { status: 403 });
  }

  return user;
}
// app/routes/dashboard.tsx - Prosta chroniona trasa
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { requireUser } from "~/utils/auth.server";

export async function loader({ request }: LoaderFunctionArgs) {
  // Wymaga zalogowania - redirect jeśli niezalogowany
  const user = await requireUser(request);

  return json({ user });
}

export default function Dashboard() {
  const { user } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Witaj, {user.name}!</p>
    </div>
  );
}
// app/routes/admin.tsx - Trasa tylko dla adminów
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { requireAdmin } from "~/utils/auth.server";

export async function loader({ request }: LoaderFunctionArgs) {
  // Tylko dla administratorów - 403 dla innych użytkowników
  const admin = await requireAdmin(request);

  const users = await db.user.findMany({
    select: { id: true, email: true, name: true, role: true },
  });

  return json({ admin, users });
}

export default function AdminPanel() {
  const { admin, users } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>Panel Administratora</h1>
      <p>Zalogowany jako: {admin.email}</p>
      <h2>Użytkownicy ({users.length})</h2>
      <ul>
        {users.map(user => (
          <li key={user.id}>
            {user.name} ({user.email}) - {user.role}
          </li>
        ))}
      </ul>
    </div>
  );
}
// app/routes/posts.$postId.edit.tsx - Ochrona właściciela zasobu
import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { requireUser, requireResourceOwner } from "~/utils/auth.server";
import { db } from "~/db.server";

export async function loader({ request, params }: LoaderFunctionArgs) {
  const { postId } = params;
  invariant(postId, "postId wymagany");

  // Sprawdź czy użytkownik jest właścicielem posta
  await requireResourceOwner(request, postId, "post");

  const post = await db.post.findUnique({
    where: { id: postId },
  });

  if (!post) {
    throw new Response("Post nie znaleziony", { status: 404 });
  }

  return json({ post });
}

export async function action({ request, params }: ActionFunctionArgs) {
  const { postId } = params;
  invariant(postId, "postId wymagany");

  // Ponowna weryfikacja w action (zawsze!)
  const user = await requireResourceOwner(request, postId, "post");

  const formData = await request.formData();
  const title = formData.get("title");
  const content = formData.get("content");

  if (typeof title !== "string" || typeof content !== "string") {
    return json({ error: "Nieprawidłowe dane" }, { status: 400 });
  }

  await db.post.update({
    where: { id: postId },
    data: { title, content },
  });

  return redirect(`/posts/${postId}`);
}

export default function EditPost() {
  const { post } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>Edytuj Post</h1>
      <Form method="post">
        <div>
          <label>
            Tytuł:
            <input name="title" defaultValue={post.title} required />
          </label>
        </div>
        <div>
          <label>
            Treść:
            <textarea name="content" defaultValue={post.content} required />
          </label>
        </div>
        <button type="submit">Zapisz</button>
      </Form>
    </div>
  );
}
// app/routes/account.tsx - Parent route chroniąca wszystkie child routes
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react";
import { requireUser } from "~/utils/auth.server";

export async function loader({ request }: LoaderFunctionArgs) {
  // Ochrona parent route chroni automatycznie wszystkie child routes
  const user = await requireUser(request);

  return json({ user });
}

export default function Account() {
  const { user } = useLoaderData<typeof loader>();

  return (
    <div>
      <nav>
        <h2>Konto użytkownika: {user.name}</h2>
        <ul>
          <li><Link to="/account/profile">Profil</Link></li>
          <li><Link to="/account/settings">Ustawienia</Link></li>
          <li><Link to="/account/billing">Płatności</Link></li>
        </ul>
      </nav>
      <main>
        {/* Child routes są automatycznie chronione */}
        <Outlet />
      </main>
    </div>
  );
}

Diagram:

flowchart TD
    A[Żądanie chronionej trasy] --> B[Loader wykonany]
    B --> C{requireUser/requireUserId}
    C --> D[Sprawdź sesję]
    D --> E{Sesja istnieje?}
    E -->|Nie| F[throw redirect '/login?redirectTo=...']
    E -->|Tak| G[Pobierz userId z sesji]
    G --> H{User istnieje w DB?}
    H -->|Nie| I[throw logout]
    H -->|Tak| J{Sprawdź uprawnienia?}
    J -->|requireAdmin| K{role === ADMIN?}
    J -->|requireRole| L{role === wymagana?}
    J -->|requireResourceOwner| M{userId === resourceUserId?}
    J -->|Brak dodatkowych| N[Zwróć user]
    K -->|Nie| O[throw Response 403]
    K -->|Tak| N
    L -->|Nie| O
    L -->|Tak| N
    M -->|Nie| O
    M -->|Tak| N
    N --> P[Pobierz dane dla route]
    P --> Q[Renderuj komponent]
    F --> R[Przekierowanie do logowania]
    I --> S[Wylogowanie i redirect]
    O --> T[Strona błędu 403]

Materiały:

↑ Powrót na górę

Remix - Sekcja 3: 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:

↑ Powrót na górę

Remix - Sekcja 5: Obsługa Błędów

25. Jak działa ErrorBoundary w Remix?

Odpowiedź w 30 sekund: ErrorBoundary w Remix to komponent eksportowany z pliku route, który przechwytuje wszystkie błędy renderowania i błędy rzucone w loaderach/actions tej trasy i jej potomków. Automatycznie zastępuje standardowy widok strony interfejsem błędu, gdy coś pójdzie nie tak. Od Remix v2, ErrorBoundary obsługuje zarówno błędy nieprzechwycone, jak i odpowiedzi HTTP z błędami (łącząc funkcjonalność CatchBoundary).

Odpowiedź w 2 minuty: ErrorBoundary to specjalna funkcja/komponent eksportowana z pliku route w Remix, który działa jako "siatka bezpieczeństwa" dla całej trasy i jej potomków. Kiedy loader, action lub komponent rzuci błąd, Remix automatycznie renderuje ErrorBoundary zamiast normalnego UI. To podejście jest bardziej granularne niż tradycyjne React Error Boundaries - każda trasa może mieć własny ErrorBoundary.

W Remix v2 nastąpiła istotna zmiana - ErrorBoundary został połączony z CatchBoundary. Teraz jeden komponent obsługuje wszystkie błędy, niezależnie czy to błędy JavaScript (Error), czy odpowiedzi Response z kodem błędu. Hook useRouteError() zwraca obiekt błędu, który można sprawdzić za pomocą isRouteErrorResponse() aby rozróżnić typy błędów.

ErrorBoundary działa kaskadowo - jeśli trasa nie ma własnego ErrorBoundary, błąd "wypływa" do najbliższego rodzica, który go ma. Dzięki temu można mieć globalny ErrorBoundary w root.tsx oraz bardziej szczegółowe w poszczególnych trasach. To pozwala na kontekstową obsługę błędów - np. błąd w trasie produktu może pokazać "Produkt nie znaleziony", podczas gdy globalny ErrorBoundary obsłuży nieoczekiwane błędy systemowe.

Ważną zaletą tego podejścia jest to, że nawet gdy część aplikacji zawiedzie, reszta layoutu (np. nawigacja, stopka) nadal może być renderowana, o ile błąd jest przechwycony na odpowiednim poziomie zagnieżdżenia tras.

Przykład kodu:

// app/routes/users.$userId.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useRouteError, isRouteErrorResponse } from "@remix-run/react";

export async function loader({ params }: LoaderFunctionArgs) {
  const user = await db.user.findUnique({
    where: { id: params.userId }
  });

  if (!user) {
    // Rzuć response z kodem 404
    throw json(
      { message: "Użytkownik nie został znaleziony" },
      { status: 404 }
    );
  }

  // Symulacja błędu serwera
  if (user.status === "corrupted") {
    throw new Error("Dane użytkownika są uszkodzone");
  }

  return json({ user });
}

// Normalny komponent - renderowany gdy wszystko OK
export default function UserPage() {
  const { user } = useLoaderData<typeof loader>();
  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

// ErrorBoundary - renderowany gdy wystąpi błąd
export function ErrorBoundary() {
  const error = useRouteError();

  // Sprawdź czy to błąd Response (404, 500, etc.)
  if (isRouteErrorResponse(error)) {
    return (
      <div className="error-container">
        <h1>{error.status} {error.statusText}</h1>
        <p>{error.data?.message || "Wystąpił błąd"}</p>
        {error.status === 404 && (
          <a href="/users">Powrót do listy użytkowników</a>
        )}
      </div>
    );
  }

  // Błąd JavaScript (Error object)
  if (error instanceof Error) {
    return (
      <div className="error-container">
        <h1>Nieoczekiwany błąd aplikacji</h1>
        <p>{error.message}</p>
        <pre>{error.stack}</pre>
      </div>
    );
  }

  // Nieznany typ błędu
  return (
    <div className="error-container">
      <h1>Nieznany błąd</h1>
      <p>Przepraszamy, coś poszło nie tak.</p>
    </div>
  );
}

Diagram:

flowchart TD
    A[Request do trasy] --> B{Loader/Action wykonany}
    B -->|Sukces| C[Renderuj normalny komponent]
    B -->|Błąd rzucony| D{Trasa ma ErrorBoundary?}
    D -->|Tak| E[Renderuj lokalny ErrorBoundary]
    D -->|Nie| F{Rodzic ma ErrorBoundary?}
    F -->|Tak| G[Renderuj ErrorBoundary rodzica]
    F -->|Nie| H[Idź wyżej w hierarchii]
    H --> F
    E --> I[Użytkownik widzi UI błędu]
    G --> I

    style E fill:#f9f,stroke:#333
    style G fill:#f9f,stroke:#333
    style C fill:#9f9,stroke:#333

Materiały:

↑ Powrót na górę

Remix - Sekcja 7: Stylowanie i Assets

33. Jakie są strategie stylowania w Remix (CSS Modules, Tailwind, CSS-in-JS)?

Odpowiedź w 30 sekund: Remix wspiera wszystkie popularne strategie stylowania, ale faworyzuje tradycyjne pliki CSS ze względu na optymalizację i progressive enhancement. Możesz używać zwykłych plików CSS, CSS Modules, Tailwind CSS, Sass, Less, czy CSS-in-JS (Styled Components, Emotion), ale każde rozwiązanie ma różne implikacje dla wydajności i doświadczenia użytkownika.

Odpowiedź w 2 minuty: Remix oferuje elastyczne podejście do stylowania, ale ze względu na swoją filozofię progressive enhancement szczególnie promuje tradycyjne pliki CSS. Każda trasa może eksportować funkcję links, która określa, które arkusze stylów powinny być załadowane dla danej trasy. To pozwala na automatyczne code-splitting stylów i ich preloadowanie.

CSS Modules są w pełni wspierane i działają podobnie jak w innych frameworkach - wystarczy użyć rozszerzenia .module.css i importować style jako obiekt JavaScript. Remix automatycznie generuje unikalne nazwy klas i obsługuje eksport przez funkcję links.

Tailwind CSS jest bardzo popularnym wyborem w ekosystemie Remix. Po zainstalowaniu i skonfigurowaniu PostCSS, Tailwind integruje się bezproblemowo z systemem budowania Remix. Możesz używać klas utility bezpośrednio w JSX, a Remix zadba o optymalizację i minifikację CSS.

CSS-in-JS (jak Styled Components czy Emotion) również działa, ale wymaga dodatkowej konfiguracji, szczególnie dla server-side rendering. Musisz zadbać o ekstrakcję stylów na serwerze i hydratację na kliencie. Choć możliwe, to podejście jest mniej zalecane w Remix ze względu na dodatkową złożoność i potencjalny wpływ na wydajność, ponieważ style są generowane w JavaScript podczas runtime zamiast być statycznymi plikami CSS.

Przykład kodu:

// 1. Zwykły CSS - app/styles/dashboard.css
import type { LinksFunction } from "@remix-run/node";
import dashboardStyles from "~/styles/dashboard.css";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: dashboardStyles },
];

// 2. CSS Modules - app/styles/card.module.css
import styles from "~/styles/card.module.css";

export default function Card() {
  return <div className={styles.card}>Zawartość karty</div>;
}

// 3. Tailwind CSS - bezpośrednio w JSX
export default function Button() {
  return (
    <button className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
      Kliknij mnie
    </button>
  );
}

// 4. CSS-in-JS (Styled Components) - wymaga dodatkowej konfiguracji
import styled from "styled-components";

const StyledButton = styled.button`
  padding: 0.5rem 1rem;
  background-color: #3b82f6;
  color: white;
  border-radius: 0.25rem;

  &:hover {
    background-color: #2563eb;
  }
`;

export default function Button() {
  return <StyledButton>Kliknij mnie</StyledButton>;
}

// 5. Sass/SCSS - app/styles/theme.scss
import type { LinksFunction } from "@remix-run/node";
import themeStyles from "~/styles/theme.scss";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: themeStyles },
];

Diagram:

flowchart TD
    A[Strategie stylowania w Remix] --> B[Tradycyjny CSS]
    A --> C[CSS Modules]
    A --> D[Tailwind CSS]
    A --> E[CSS-in-JS]
    A --> F[Preprocesory CSS]

    B --> B1[Zwykłe pliki .css]
    B --> B2[Funkcja links]
    B --> B3[Route-based splitting]

    C --> C1[.module.css]
    C --> C2[Scoped class names]
    C --> C3[Import jako obiekt]

    D --> D1[Utility classes]
    D --> D2[PostCSS config]
    D --> D3[JIT compilation]

    E --> E1[Styled Components]
    E --> E2[Emotion]
    E --> E3[SSR config wymagana]

    F --> F1[Sass/SCSS]
    F --> F2[Less]
    F --> F3[PostCSS]

    style B fill:#90EE90
    style C fill:#90EE90
    style D fill:#90EE90
    style E fill:#FFB6C1
    style F fill:#90EE90

Materiały

↑ Powrót na górę

Remix - Sekcja 8: Optymalizacja i Wydajność

36. Jak Remix optymalizuje ładowanie danych dzięki nested routing?

Odpowiedź w 30 sekund: Remix wykorzystuje nested routing do równoległego ładowania danych dla wszystkich zagnieżdżonych tras jednocześnie. Zamiast kaskadowego ładowania (rodzic → dziecko → wnuk), Remix identyfikuje wszystkie dopasowane trasy i wywołuje ich loadery równolegle, co znacząco skraca całkowity czas ładowania.

Odpowiedź w 2 minuty: W tradycyjnych frameworkach routing zagnieżdżony często prowadzi do tzw. "waterfall effect", gdzie każdy poziom zagnieżdżenia musi załadować swoje dane przed przejściem do kolejnego poziomu. Remix radykalnie zmienia to podejście poprzez wykorzystanie informacji o strukturze tras podczas kompilacji.

Gdy użytkownik nawiguje do zagnieżdżonej trasy, Remix natychmiast identyfikuje wszystkie komponenty, które będą renderowane (od roota przez wszystkie zagnieżdżone layouty aż do docelowej strony) i wywołuje wszystkie ich loadery równocześnie w jednym żądaniu. Na przykład, dla trasy /dashboard/settings/profile, Remix wywoła równolegle loadery z root.tsx, dashboard.tsx, dashboard.settings.tsx oraz dashboard.settings.profile.tsx.

Dodatkowo, Remix optymalizuje ponowne ładowanie danych podczas nawigacji między trasami o wspólnych rodzicach. Jeśli przechodzisz z /dashboard/settings/profile do /dashboard/settings/notifications, Remix ponownie załaduje tylko dane dla komponentu notifications.tsx, gdyż dane z root.tsx, dashboard.tsx i settings.tsx pozostają aktualne. Ta inteligentna rewalidacja znacząco zmniejsza ilość przesyłanych danych i poprawia wydajność aplikacji.

Mechanizm ten działa automatycznie - nie wymaga dodatkowej konfiguracji ze strony dewelopera. Remix wykorzystuje swoją wiedzę o strukturze routingu i automatycznie optymalizuje przepływ danych.

Przykład kodu:

// app/routes/dashboard.tsx - Loader rodzica
export async function loader({ request }: LoaderFunctionArgs) {
  console.log('Dashboard loader - wywołany równolegle');
  const user = await getUser(request);
  return json({ user });
}

// app/routes/dashboard.settings.tsx - Loader zagnieżdżony poziom 1
export async function loader({ request }: LoaderFunctionArgs) {
  console.log('Settings loader - wywołany równolegle z dashboard');
  const settings = await getUserSettings(request);
  return json({ settings });
}

// app/routes/dashboard.settings.profile.tsx - Loader zagnieżdżony poziom 2
export async function loader({ request }: LoaderFunctionArgs) {
  console.log('Profile loader - wywołany równolegle z pozostałymi');
  const profile = await getUserProfile(request);
  return json({ profile });
}

// Wszystkie trzy loadery powyżej są wywoływane RÓWNOCZEŚNIE,
// a nie kaskadowo jak w tradycyjnych rozwiązaniach
export default function Profile() {
  const { profile } = useLoaderData<typeof loader>();
  // Dashboard i Settings dane są dostępne przez useRouteLoaderData()
  return <div>{profile.name}</div>;
}

Diagram:

flowchart LR
    A[Nawigacja do /dashboard/settings/profile] --> B{Remix identyfikuje wszystkie trasy}
    B --> C[root.tsx loader]
    B --> D[dashboard.tsx loader]
    B --> E[dashboard.settings.tsx loader]
    B --> F[dashboard.settings.profile.tsx loader]
    C --> G[Wszystkie loadery wykonują się równolegle]
    D --> G
    E --> G
    F --> G
    G --> H[Renderowanie po zakończeniu wszystkich loaderów]

Materiały

↑ Powrót na górę

Remix - Section 9: Deployment i Konfiguracja

40. Jakie adaptery deployment oferuje Remix (Vercel, Cloudflare, Node)?

Odpowiedź w 30 sekund: Remix oferuje różne adaptery deployment dostosowane do środowisk hostingowych: @remix-run/node dla tradycyjnych serwerów Node.js, @remix-run/vercel dla platformy Vercel, @remix-run/cloudflare-pages i @remix-run/cloudflare-workers dla Cloudflare, oraz adaptery dla Netlify, Deno, Architect i innych platform. Każdy adapter tłumaczy Remix na specyficzne API danego środowiska.

Odpowiedź w 2 minuty: Remix wykorzystuje architekturę adapterów, która pozwala na deployment aplikacji do różnych środowisk bez zmian w kodzie aplikacji. Główne adaptery to:

Adaptery Serverless i Edge:

  • @remix-run/vercel - dla Vercel (zarówno serverless functions jak i Edge Functions)
  • @remix-run/cloudflare-pages - dla Cloudflare Pages
  • @remix-run/cloudflare-workers - dla Cloudflare Workers
  • @remix-run/netlify - dla Netlify Functions
  • @remix-run/architect - dla AWS (API Gateway + Lambda)

Adaptery dla tradycyjnych serwerów:

  • @remix-run/node - dla Node.js (Express, Fastify, etc.)
  • @remix-run/deno - dla Deno Deploy

Każdy adapter implementuje ten sam interface Remix, ale dostosowuje go do specyficznych wymagań platformy. Na przykład, adapter Cloudflare Workers wykorzystuje Workers KV do cache'owania, podczas gdy adapter Node.js może używać filesystem lub Redis. Adapter odpowiada za inicjalizację serwera, obsługę requestów HTTP, zarządzanie sesjami i cookies oraz dostęp do zasobów specyficznych dla platformy (zmienne środowiskowe, storage, etc.).

Wybór adaptera wpływa na dostępne funkcjonalności - edge adaptery (Cloudflare, Vercel Edge) oferują niską latencję globalnie, ale mają ograniczenia runtime, podczas gdy Node.js daje pełną kontrolę i możliwość użycia dowolnych bibliotek npm.

Przykład kodu:

// remix.config.js - konfiguracja dla różnych adapterów

// 1. Node.js (tradycyjny serwer)
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  serverModuleFormat: "cjs",
  server: "./server.js", // Własny serwer Express/Fastify
};

// server.js - przykład z Express
import { createRequestHandler } from "@remix-run/express";
import express from "express";

const app = express();
app.all("*", createRequestHandler({ build: require("./build") }));

// 2. Vercel - automatyczna konfiguracja
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  serverModuleFormat: "esm",
  // Vercel automatycznie wykrywa Remix
};

// 3. Cloudflare Workers
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  serverModuleFormat: "esm",
  server: "./server.ts",
  serverConditions: ["workerd", "worker", "browser"],
  serverDependenciesToBundle: "all", // Bundluj wszystko dla Workers
  serverMainFields: ["browser", "module", "main"],
  serverMinify: true,
  serverPlatform: "neutral",
};

// server.ts dla Cloudflare Workers
import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";
import * as build from "@remix-run/dev/server-build";

export const onRequest = createPagesFunctionHandler({
  build,
  getLoadContext: (context) => ({
    // Dostęp do Cloudflare bindings
    env: context.env,
    cloudflare: {
      cf: context.request.cf,
      ctx: context,
    },
  }),
});

// 4. Netlify
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  serverModuleFormat: "cjs",
  // Netlify automatycznie wykrywa Remix
};

// Użycie context specyficznego dla platformy w loaderze
// app/routes/_index.tsx
import type { LoaderFunctionArgs } from "@remix-run/cloudflare";

export async function loader({ context }: LoaderFunctionArgs) {
  // Cloudflare Workers KV
  const value = await context.env.MY_KV.get("key");

  // Vercel KV (Redis)
  // const value = await context.kv.get("key");

  // Node.js - filesystem lub dowolna baza
  // const value = await db.query("SELECT ...");

  return { value };
}

// Porównanie capabilities różnych adapterów
interface AdapterCapabilities {
  edge: boolean;          // Czy działa na edge
  coldStart: string;      // Czas cold start
  maxDuration: string;    // Max czas wykonania
  runtime: string;        // Dostępne runtime APIs
}

const capabilities: Record<string, AdapterCapabilities> = {
  "cloudflare-workers": {
    edge: true,
    coldStart: "~0ms",
    maxDuration: "CPU time limited",
    runtime: "V8 isolates - Web APIs only"
  },
  "vercel-edge": {
    edge: true,
    coldStart: "~0ms",
    maxDuration: "30s",
    runtime: "V8 isolates - Web APIs only"
  },
  "node": {
    edge: false,
    coldStart: "Depends on host",
    maxDuration: "Unlimited",
    runtime: "Full Node.js APIs"
  },
  "vercel-serverless": {
    edge: false,
    coldStart: "~100-300ms",
    maxDuration: "10s (hobby), 60s (pro)",
    runtime: "Full Node.js APIs"
  }
};

Diagram:

flowchart TD
    A[Remix App Code] --> B{Adapter Layer}

    B -->|@remix-run/node| C[Node.js Server]
    B -->|@remix-run/vercel| D[Vercel Platform]
    B -->|@remix-run/cloudflare-*| E[Cloudflare Platform]
    B -->|@remix-run/netlify| F[Netlify Platform]

    C --> C1[Express/Fastify]
    C --> C2[Full Node.js APIs]
    C --> C3[Własny hosting]

    D --> D1[Serverless Functions]
    D --> D2[Edge Functions]
    D --> D3[Vercel KV/Blob]

    E --> E1[Workers/Pages]
    E --> E2[KV/D1/R2]
    E --> E3[Global Edge Network]

    F --> F1[Netlify Functions]
    F --> F2[Edge Functions]
    F --> F3[Netlify Blobs]

    style B fill:#ff0
    style C fill:#9f9
    style D fill:#99f
    style E fill:#f99
    style F fill:#9ff

Diagram architektury adaptera:

flowchart LR
    subgraph App["Aplikacja Remix"]
        R[Routes]
        L[Loaders]
        A[Actions]
    end

    subgraph Adapter["Adapter Layer"]
        H[HTTP Handler]
        S[Session Storage]
        C[Cookie Parser]
        CT[Context Builder]
    end

    subgraph Platform["Specyfika Platformy"]
        N1[Node: fs, process]
        N2[Vercel: env, KV]
        N3[Cloudflare: bindings, KV]
        N4[Netlify: functions, env]
    end

    Request[HTTP Request] --> H
    H --> CT
    CT --> N1 & N2 & N3 & N4
    CT --> L & A
    L & A --> Response[HTTP Response]

    style Adapter fill:#ff9
    style Platform fill:#9ff

Materiały

↑ Powrót na górę

Chcesz więcej pytań?

Uzyskaj dostęp do 800+ pytań z 13 technologii - JavaScript, React, TypeScript, Node.js, SQL i więcej. Natychmiastowy dostęp na 30 dni.

Kup pełny dostęp za 49,99 zł