Fronteers — vakvereniging voor front-end developers

Remix en Next.js: "echt" full-stack bestaat niet, of wel?

Een goede blog begint altijd met een controversiële titel, zeggen ze toch? Maar er zit wel een gedachte achter.

Een "echte" full-stacker, die zijn ontzettend zeldzaam. In mijn jarenlange carrière heb ik er 1, misschien 2 ontmoet. Let wel, met "echte" full-stacker bedoel ik de term die HR en management helaas vaak gebruiken: iemand die 50% op front-end en 50% op back-end ingezet kan worden.

Dit is lastig, want front-end en back-end (in ieder geval de wat klassiekere variant) zijn twee complete disciplines. Ieder met hun eigen languages en tools, met hun eigen best practices, met hun eigen thought leaders, en ga zo maar door.

Op de werkvloer komt het geregeld voor dat IT-afdelingen zoeken naar (enkel) full-stack ontwikkelaars omdat die flexibel en -vooral- goedkoop zijn. Je krijgt 2 ontwikkelaars voor de prijs van één toch? In de praktijk krijg je meestal iemand die een expert is één discipline, en "goed genoeg" in de andere.

Goed, ik kan hier eindeloos over doorpraten, maar het punt is duidelijk: full-stack is moeilijk om goed te kunnen/doen.

Het is daarom geinig dat er de laatste jaren front-end meta-frameworks op zijn gekomen die front-enders stiekem in full-stack developers veranderen. We duiken in 2 frameworks die hier "schuldig" aan zijn: Remix en Next.js

Trouwens, "schuldig" moet je met een korrel zout nemen. Het is namelijk, als je het mij vraagt, een goede ontwikkeling.

Wat zijn meta-frameworks?

Remix en Next.js worden tegenwoordig vaak "meta-frameworks" genoemd, omdat ze meer doen dan alleen zorgen dat je met React een user interface kan bouwen. Meta-frameworks hebben ook controle over de server waar je applicatie op draait.

Dit is een hele grote reden waarom Remix (en Next.js) het zo makkelijk maken om full-stack te werken. Ze weten beide welk deel van je code server-side en welk deel client-side is. En daardoor kunnen ze een aantal aannames maken:

  • Het is makkelijk om de code te splitten: de browser downloadt alleen de client-side code en niks van de server-side code waar het niks mee kan.
  • Routing kan worden ingebouwd en is niet meer enkel client-side: we gaan als het ware weg van de single-page applications en weer richting de multi-page applications.
  • De weg tussen client-side en server-side is makkelijker omdat die wordt gecontroleerd door het meta-framework. Dit zie je in zowel Remix als Next.js, waar de server-side code al geprocessed is (Next.js geeft je in zijn NextAPIRequest object een body die al geparsed is door de content-type header (dit kan je uitzetten trouwens), Remix revalideert telkens de data nadat een <form/> is verstuurd).

Van de server naar de client naar de server

Wat maakt het dan zo interessant om een meta-framework in te zetten? En belangrijker: hoe vertroebelt een meta-framework de grens tussen front-end en back-end? We pakken even Next.js als voorbeeld.

Begin november was het Next.js conference, waar één van de sprekers Theo Browne(@t3dotgg) was. In zijn presentatie spreekt hij over Next.js als back-end framework (dit klinkt heel bekend, maar ik beloof je dat ik het voorstel voor dit artikel eerder had ingeschoten bij Fronteers). Het stuk waar ik naar link gaat dieper in over de plek die Next.js inneemt op de reis van database via server naar de client.

Op die reis kunnen we een paar "stations" pakken:

  1. De database: er wordt een connectie gelegd met een database en data uitgevraagd.
  2. Een request: er wordt een request via de server verstuurd en uitgevoerd.
  3. HTML: er wordt HTML gegenereerd die naar de client wordt gestuurd.
  4. Interactie: de HTML wordt gedownload en ingelezen, de JavaScript draait en het framework (in ons geval React) neemt het over.

Hier wordt goed uitgelicht hoe een meta-framework als Next.js een bredere plek inneemt op die reis. Waar we eerst PHP gebruikten voor stations 1, 2 en 3, en HTML voor station 4, kunnen we met Next.js door alle stations gaan. In feite maakt Next.js het mogelijk om in onze applicatie (of website, waarom niet?) van database naar de client te gaan.

In het voorbeeld hieronder heb ik een simpele Next.js page, die op de server data ophaalt uit de database en vervolgens de pagina vult:

// In db.js hebben we een connectie naar de database gemaakt
// db.js draait op de server (station 1)
import { db } from '../db';

// Het Page component rendert een pagina met de data die we
// van de server hebben gehaald
// Page() wordt gegenereerd op de server (station 3) maar draait
// op de client (station 4)
export default function Page({ products }) {
return (
<div>
<h1>Onze producten</h1>

<ul>
{ products.map(product => (
// Een overzicht van een product
))}
</ul>
</div>
);
}

// getServerSide props vraagt data op uit de database
// Deze functie draait alleen op de server en wordt aangeroepen
// op page load (station 2)
export async function getServerSideProps() {
const products = await db.product.findMany();

// Pass data to the page via props
return { props: { products } };
}

Dit voorbeeld laat zien hoe je in 33 regels de hele reis van database naar client kunt maken. Meta-frameworks zoals Next.js en Remix maken veranderen de grens tussen "puur" front-end en full-stack, omdat ze ons een aantal voordelen bieden. Bijvoorbeeld de mogelijk dat we de hele reis in 1 taal kunnen doen (in plaats van te moeten switchen tussen PHP en HTML). Ook geven ze ons, als we TypeScript gebruiken, de mogelijkheid onze types te hergebruiken op zowel de client als de server. Dit geldt trouwens ook voor data validatie. Context-switching? Dat is nu een stuk minder geworden.

Meta-frameworks bieden ook al oplossingen voor het routen van je applicatie of website. In het geval van Remix en Next.js is date file-based: voor iedere route in je project maak je een bestand aan dat jouw pagina representeert.

Met de RFC over React Server Components zien we dat React ook deze kant op aan het bewegen is. Je zien dat deze manier van denken steeds meer voeten aan de grond begint te krijgen in de wereld van front-end.

Wat is Remix?

Maar waar valt Remix in dit verhaal?

De meeste van ons kennen of werken met Next.js, dat pas geleden alweer versie 13 presenteerde met ontzettend veel nieuwe updates. Next.js presenteert zichzelf als hét React framework. Oftewel: de beste manier om een React applicatie te bouwen is met Next.js. Het React-team doet hieraan mee door Next.js aan te raden in hun (nieuwe) documentatie (wel met een special mention voor Remix!). Het is ook interessant dat een van de grootste nieuwe features in React, namelijk React Server Components, eerst gedebuteerd werd in een RFC voor Next.js 13 dan op de website van React zelf. Next.js heeft lang het React-landschap gedomineerd als framework. Tot nu toe, want ineens was daar Remix.

Remix is gemaakt door Ryan Florence en Michael Jackson (nee niet díe), de bedenkers van React Router. Zij wilden een framework maken dat focused op web standaarden en moderne UX (dit zijn hun woorden). Remix richt zich op "use the platform" en maakt daar zoveel mogelijk gebruik van hoe een browser werkt of wat de HTTP standaard vertelt. Component-scoped CSS? Hier heb je geen CSS-In-JS of CSS Modules voor nodig, in Remix exporteer je een links() export voor je component of route en Remix voegt een `` tag toe op de noodzakelijke routes.

Progressive enhancement is ook een belangrijk onderdeel van de principes van Remix. Het is bijvoorbeeld niet alleen makkelijk om een werkende applicatie te bouwen in Remix dat client side *geen JavaScript* gebruikt, Remix duwt je zelfs die kant een beetje in. Een groot deel van applicatie functionaliteiten waar we tegenwoordig JavaScript voor inzetten, zoals het ophalen en versturen van form data, werkt in Remix simpelweg met een <form method="post">, een loader functie die aangeroepen wordt op GET requests (dus ook page loads, als je route een loader functie export) en een action functie die op POST (en PUT) requests draait. Is JavaScript niet beschikbaar op een browser? Dan wordt het formulier verstuurd via een redirect naar de server en terug naar de client (zoals we vroeger deden met PHP). Is JavaScript er wel? Dan wordt de request op de pagina zelf gedaan zonder redirect en krijgt de gebruiker automatisch de nieuwe data te zien.

Tot nu toe hebben we het op een redelijk luchtig niveau gehad over full-stack en meta-frameworks, maar je zou je kunnen afvragen hoe een meta-framework ons precies de full-stack kant induwt?

Op weg naar full-stack

We duiken die vraag in met hulp van een aantal voorbeelden. We zien ook hoe bepaalde dingen die we eerst aan de back-end zouden toeschrijven naar de front-end worden gehaald.

Databases

We beginnen met databases, want veel projecten zullen er een nodig hebben voor data opslag.

Traditioneel gesproken zal de client van een applicatie/website via een API data opvragen aan de server. Het is dan aan de server om een connectie met de database te leggen, data op te vragen, en het resultaat terug te sturen (of een foutmelding weer te geven indien nodig). Dit is natuurlijk helemaal prima, en in veel gevallen hoeft dit niet per se te veranderen. We kijken straks naar een manier om Remix of Next.js in te zetten in een architectuur met een externe database.

Wat nu mogelijk is, is om in Remix of Next.js direct een verbinding met je database te leggen. Dit wordt helemaal makkelijk als je een tool als Prisma inzet als ORM. Prisma gaf je al het voordeel om je ORM in JavaScript of TypeScript te schrijven (komen we toch weer terug op context switching), dat je vervolgens kwijt kon tussen je project en je database.

Die laag kan je nu direct in je project kwijt, door Prisma aan te roepen en vervolgens data uit te vragen of weg te schrijven. Dit kan je vervolgens meteen in je pagina kwijt.

Remix geeft een simpel voorbeeld hiervan, maar dit is uiteraard ook in Next.js mogelijk.

Proxy

Inhakend op het database-verhaal: wat als je een front-end project wil omzetten naar een meta-framework maar de back-end met alle database operaties staan al vast?

Nou, als eerste wil ik je zeggen: je hoeft niet alles om te bouwen naar iets nieuws omdat het iets nieuws is. Als de infrastructuur rondom de back-end en database al staat, gebruik je lekker dat. Het scheelt een boel tijd en moeite voor iets dat al werkt.

Wat je wel kan doen, is de server van je meta-framework gebruiken als proxy. Dit biedt weer een boel nieuwe opties en mogelijkheden:

  • Je kan requests load-balancen en responses cachen.
  • Je kan environment variables gebruiken voor bepaalde secret tokens.
  • Je kan de response van requests naar je database of een externe back-end muteren voor je het terugstuurt (hier kom ik straks nog op terug).

In je meta-framework kan je je server inzetten om requests naar een (externe) back-end te sturen, eventueel voorzien van een authenticatie token.

In het voorbeeld hieronder gebruiken we Notion om een rij in een tabel (wat bij Notion een "database" heet) toe te voegen. Voor het voorbeeld gebruik ik Remix, maar dit is uiteraard ook mogelijk in Next.js.

// ./app/notion.server.ts, wordt alleen op de server ingeladen
import { Client } from '@notionhq/client';

export const notionClient = new Client({
// Het authenticatie token is alleen in te lezen op de server
auth: process.env.NOTION_AUTH_TOKEN,
});

// ./app/routes/notion.ts
import { notionClient } from '../notion.server';
import type { ActionArgs } from '@remix-run/node';

// `action` functies worden door Remix aangeroepen wanneer bij
// POST en PUT requests. Deze draaien op de server
export const action = async ({ request }: ActionArgs) => {
// We parsen de body uit onze request. Als het verstuurd is
// door een `<form/>`, krijgen we een FormData object
const payload = await request.formData();
const data = Object.fromEntries(payload);

// We maken een nieuwe rij aan in de tabel,
// met de data die is versturd via het <form/>
const response = await notionClient.pages.create({
parent: {
// NOTION_CONTACT_FORM_TABLE_ID is alleen uit te lezen
// zien op de server
database_id: process.env.NOTION_CONTACT_FORM_TABLE_ID,
},
properties: {
// De data die je wilt weg schrijven in je tabel
...data,
}
});

if (!response.ok) {
// Er is iets fout gegaan, dus we sturen een error terug
// Omdat we de response `throw`'en, weet Remix dat er iets
// fout is gegaan. Hier kunnen wij dan weer op acteren
// Hier komen we later op terug
throw new Response(JSON.stringify({ status: 'error' }), {
status: 500,
});
}

// Notion heeft de rij gemaakt, we sturen een response terug
return new Response(JSON.stringify({ status: 'success' }), {
status: 201
});
};

Even een side note: Remix heeft helper functions zoals json() dat je kan gebruiken om response terug te sturen. Die helpers zijn kleine wrappers om het daadwerkelijke Response object heen. Voor dit voorbeeld wilde ik meer focussen op wat de server kan, dan hoe Remix specifiek werkt. Deze code kan je namelijk ook in Next.js gebruiken.

Dure operaties off-loaden naar de server

Het komt soms voor dat we een dure operatie moeten uitvoeren. Met "duur" bedoel ik dan een operatie die:

  • Veel processorkracht kost;
  • Veel data heen of terugstuurt;

Denk hierbij vooral aan het on-the-fly genereren van responsive afbeeldingen, het omgaan met grote payloads in requests of responses (scheelt weer een GraphQL server), het sanitizen van input data van de gebruiker, of het parsen van markdown uit de database naar HTML voor de client (zoals bij een blog).

Het idee is dat de servers draaien op hardware die gemaakt is om grote operaties efficiënt uit te voeren. De client is altijd afhankelijk van het apparaat dat de gebruiker heeft, hoeveel apps of programma's er toevallig tegelijkertijd aan het draaien zijn, en de internetverbinding of -abonnement dat de gebruiker heeft.

Door de dure operaties op de server plaats te laten vinden, kunnen we onze applicatie/website sneller laten reageren, kunnen we voorkomen dat er kostbare kilobytes over de lijn worden verstuurd en kunnen we (in erge gevallen) zelfs voorkomen dat onze applicatie of website crashed.

Data mutaties

Met data mutaties bedoel ik het lezen en schrijven van data. Meta-frameworks spelen hier heel handig op in omdat ze in staat zijn de mutaties optimaal in te plannen.

Data lezen

Wanneer je op de client-side een GET request doet voor data, moet eerst de JavaScript bundle worden gedownload en geparsed, voor React kan draaien om de request uit te voeren voor je component. Dit is in feite een waterval. We moeten namelijk steeds wachten op een stukje dat geladen wordt, voor we het volgende stuk kunnen ophalen.

Wat meta-frameworks kunnen doen, is de request voor de data op de server uitvoeren. In de tijd dat de client een JavaScript bundle krijgt om te download, te parsen en te draaien, kan de request uitspelen en kan de HTML-pagina worden gegenereerd met de opgevraagde data. We hoeven niet meer stapsgewijs de netwerk requests af te wachten. Performance!

Data muteren

Een ander voordeel is dat het makkelijker is om data te muteren. Dit is het beste uit te leggen aan de hand van een voorbeeldje.

Stel, we maken een website waarop iemand data van voertuigen uit kan lezen. Er is een database, die bereikbaar is via een REST API, waarbij we een simpele GET request kunnen sturen dat we als volgt kunnen typeren:

interface VehicleQuery {
type: 'car' | 'bus' | 'truck';
licensePlate: string;
}

Iedere query geeft een antwoord met de gegevens van dat voertuig:

interface VehicleDetails {
make: string;
model: string;
fuelType: string;
engineType: string;
maxOccupants: number;
ownerHistory: Owner[];
}

Onze opdracht is om een pagina te maken waarbij iemand zijn kentekenplaat in kan vullen en het merk en model terugkrijgt. Hoewel de API meer informatie kan ontvangen en teruggeven, moeten we voor onze pagina (zie het als een soort landingspagina) er meer voor zorgen dat er z.s.m. data op het scherm te zien is. We vragen dus alleen het kentekenplaat uit, want we stellen dat het voertuigtype (dat wel verplicht is in de query) altijd car zal zijn.

We pakken Remix even als voorbeeld:

import type { json } from '@remix-run/node';
import type { ActionArgs } from '@remix-run/node';
import { useActionData } from '@remix-run/react';
// `~` is ge-aliased naar `./app/`
import type { VehicleQuery } from '~/models/Vehicle';

export const action = async ({ request }: ActionArgs) => {
const data = await request.formData();
const { licensePlate } = Object.fromEntries(data);

const query: VehicleQuery = {
type: 'car';
licensePlate,
};

const response = await fetch('/api/vehicles/query', {
method: 'POST',
body: JSON.stringify(query),
});

const { make, model } = await response.json();

return json({ make, model });
};

export default function VehicleInfoPage() {
const data = useActionData<typeof action>();

if (data) {
const { make, model } = data;

return (
<div>
De gegevens van het voertuig zijn:

<ul>
<li>Merk: { make }</li>
<li>Model: { model }</li>
</ul>
</div>
);
}

return (
<form method="post">
<label>
<p>Wat is het kenteken?</p>
<input name="licensePlate" type="text" />
</label>

<button>Haal informatie op</button>
</form>
);
}

Wacht, wat gebeurt hier?

We beginnen even met de pagina zelf, die laat namelijk een formulier zien waar iemand een kenteken kan invoeren. Zaken als styling is even weggelaten om het simpel te houden.

Wanneer op de knop geklikt wordt, zal Remix een POST request uitvoeren. Onder water wordt er veel gedaan, maar in ons voorbeeld gaat het erom dat de action functie wordt aangeroepen.

In de action, halen we uit de request het kenteken dat de gebruiker heeft ingevoerd. De query vraagt naast een kenteken ook om een voertuigtype, maar dat hoeft de gebruiker niet in te vullen. Wij zetten het naar car, en vullen de query aan met het kenteken en hoppa!

Dit is natuurlijk een erg simpel voorbeeld, maar als je wilt interacteren met een API dat veel data vraagt in zijn request én jij weet dat veel properties naar een standaard waarde worden ingesteld, hoef je die niet te vragen in de request van jouw client naar de server. Je stuurt enkel de data die daadwerkelijk ingevuld wordt door de gebruiker, vult het aan met alle standaard data (of aangevulde business logica, zoals misschien een userID dat je uit je sessie data haalt) en stuurt dit naar de client. In ons voorbeeld bevat VehicleQuery maar 2 properties, maar wat als je een API hebt dat 20-30 properties heeft waarvan 80% een standaard waarde heeft?

Dan het vervolg: de query is gelukt en we hebben informatie gekregen van de API. Er is meer data teruggekomen dan we daadwerkelijk nodig hebben, dus we pakken de velden die we wél willen (make en model), en sturen dit terug naar de client.

In de client kunnen we een check doen of er data is verstuurd van de server (dit is een Remix-specifiek iets, daar kom ik later op terug), en als dat het geval is laten we het antwoord van de server zien. Stel de API geeft een gigantische lading aan data terug, dan kan je op je server gemakkelijk de informatie uitpikken die je nodig hebt en dit terugsturen naar de client.

GraphQL, anyone?

Nu is de payload, zowel van de client naar de server als vice versa, zo klein mogelijk. Dit is ontzettend handig, want iedere kilobyte telt!

Hergebruik van types en validatie

Het is bij Remix en Next.js (en waarschijnlijk ook bij andere meta-frameworks) mogelijk om de types en validaties die je gebruikt in je code te delen tussen de client en de server.

Zo kan je een validatie function schrijven voor een e-mailadres. Die functie kan je gebruiken op de server om een inschrijfformulier te valideren voor je de data verwerkt in je database (want back-end validatie is essentieel in data verwerking).

Maar, in het kader van progressive enhancement, willen we ook het e-mailadres valideren als de gebruiker het e-mail veld heeft ingevuld en vervolgens naar een ander veld gaat.

In dit geval kunnen we dezelfde validatie functie gebruiken, en deze op de client draaien. We hergebruiken de validatie tussen de client en de server zonder problemen.

// ./app/validations/email.ts
export function emailIsValid(email: string) {
return email.match(/^(([^<>()\\\\[\\\\]\\\\\\\\.,;:\\\\s@"]+(\\\\.[^<>()\\\\[\\\\]\\\\\\\\.,;:\\\\s@"]+)*)|(".+"))@((\\\\[[0-9]{1,3}\\\\.[0-9]{1,3}\\\\.[0-9]{1,3}\\\\.[0-9]{1,3}])|(([a-zA-Z\\\\-0-9]+\\\\.)+[a-zA-Z]{2,}))$/);
}

// ./app/routes/contact.tsx
import type { ActionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useActionData } from '@remix-run/react';
import { emailIsValid } from '~/validations/email';

interface SuccessfulContactFormResponse {
status: 'success';
}

interface FailedContactFormResponse {
status: 'error';
message: string;
}
type ContactFormResponse =
| SuccessfulContactFormResponse
| FailedContactFormResponse;

type ContactForm = {
email: string;
message: string;
};

export const action = async ({ request }: ActionArgs) => {
const data = await request.formData();
const { email, message } = Object.fromEntries(data) as ContactForm;

if (!emailIsValid(email)) {
// Het e-mailadres is niet geldig, dus we sturen een error response terug
const response: ContactFormResponse = {
status: 'error',
message: 'E-mail is niet correct',
};

return json(response, { status: 500 });
}

// Hier doen we iets met `message` om het te versturen
// of op te slaan.
const response: ContactFormResponse = { status: 'success' };
return json(response);
};

export default function ContactPage() {
const data = useActionData<typeof action>();

return (
<form method="post">
<label>
<p>E-mail:</p>
<input
name="email"
type="email"
aria-invalid={data?.status === 'error' ? true : undefined}
aria-describedby="email-error"
/>
{ data?.status === 'error' && (
<p id="email-error">
{data.message}
</p>
)}
</label>

<label>
<p>Bericht:</p>
<textarea name="message" />
</label>

<button>Verzenden</button>
</form>
);
}

Zoals je ziet in het voorbeeld kunnen we hetzelfde principe toepassen voor TypeScript types: zowel de server als de client kan ze gebruiken.

Als je Remix al kent, zie je dat ik useActionData<typeof action>() gebruik. In feite geeft dit al het correcte type mee voor data in mijn code editor en hoef ik die niet expliciet te typen voor TypeScript. Je kan het altijd nog expliciet meegeven aan data wanneer je het declareert.

State management

De diepe samenwerking tussen client en server in meta-frameworks komt ook van pas als het gaat om state management.

Over de jaren heen zijn er vele mogelijkheden gekomen om state management te doen in React. Dit komt met name omdat applicaties complexer worden én omdat data een steeds grotere rol aan het spelen is in die applicaties. Het komt er grofweg op neer dat een applicatie (dit geldt soms ook voor websites trouwens) rekening moet houden met 2 zaken:

  • De application state, oftewel de global state van een applicatie;
  • Data mutaties, oftewel het aannemen, verwerken en weergeven van gebruikersdata;

Meestal wordt er een state management library gekozen om dit allemaal in bij te houden. Daar komt ook bij dat in Create React App deze state management voornamelijk (of zelfs helemaal) op de client wordt gedaan. Maar het leuke van meta-frameworks is dat ze de mogelijkheid bieden hier slimmer mee om te gaan.

Application state

Eerst kijken we naar application state, wat wordt daar nou mee bedoeld?

In de meeste applicaties of websites wil je bepaalde states bijhouden zodat de gebruiker bijvoorbeeld functionaliteiten kan doen of instellingen wil bijhouden. Ik pak het voorbeeld van een dark mode toggle. Op de meeste applicaties of websites zit er in het menubalkje een knop waarmee je kan aangeven of je de lichte of donkere versie van de website wil zien.

Op mijn website zie je zo'n toggle button zitten, ik heb hem gefocused met mijn keyboard want waarom niet? ;)

Mijn website in light mode, waarbij je een lichte achtergrond en donkere tekst ziet

Als je er op klikt verandert het kleurenschema in een donkere variant.

mijn website in dark mode, waarbij je een donkere achtergrond en lichte tekst ziet

De standaardmode van de website is light mode, dus als je de website bezoekt en je hebt nog geen voorkeur aangegeven door te klikken op de knop (of je hebt geen prefers-color-scheme instelling) is de achtergrond wit en de tekst donkerblauw.

Nu is het de bedoeling dat de mode die je kiest wordt onthouden op iedere pagina die je bezoekt. Klik je op de homepage op het zonnetje, wordt de achtergrond donkerblauw en de tekst wit. Dit krijg je dan ook te zien op ieder volgende pagina.

In een standaard React app kunnen we deze state onthouden met behulp van een state management library. Dit is zo simpel als het instellen van een default state, en wanneer op de knop wordt geklikt updaten we de default state met de nieuwe waarde. Hoe dit precies gaat verschilt een beetje per state management library, maar het eindresultaat is hetzelfde: de state wordt overal onthouden.

Hier is het wel belangrijk om even te benoemen dat die state meestal alleen lokaal wordt onthouden. Refresh je de pagina, verlies je de bijgewerkte state. Dit kan je makkelijk verhelpen door de state op te slaan in een cookie of localStorage.

Mijn website is gebouwd in Remix (surprise, surprise), en de manier hoe ik het oplos is simpelweg met cookies. De implementatie bestaat uit 3 punten:

  1. We maken een cookie die door de browsers wordt meegestuurd met iedere request.
  2. We bouwen een <DarkModeToggle/> knop die via een prop weet of die aan of uit staat (en zijn icoontje aanpast) en een POST doet naar een endpoint op de server als je er op klikt.
  3. We bouwen de endpoint op de server om de cookie aan te passen met de nieuwe dark mode state.

Stap 1: We maken een cookie die door de browsers wordt meegestuurd met iedere request.

In simpele vorm ziet dit er zo uit, waarbij we beginnen met het maken van een cookie. Dit wordt dan door de browser meegestuurd met iedere request.

// ./app/cookie.ts
import { createCookie } from '@remix-run/node';

export const userPrefs = createCookie('user-prefs', {
maxAge: 31536000, // a year
});

In ./app/root.tsx kunnen we dit dan uitlezen. De root in een Remix applicatie is wat _app is binnen een Next.js applicatie. Alle routes worden hierin meegegeven en wij hebben de mogelijkheid om het skelet van de applicatie of website vorm te geven.

import type { ActionArgs } from '@remix-run/node';
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react';
import { userPrefs } from '~/cookies';
import DarkModeToggle from '~/components/DarkModeToggle';

export const loader = async ({ request }: LoaderArgs) => {
const cookieHeader = request.headers.get('Cookie');
const { darkModeEnabled = false } = await userPrefs.parse(cookieHeader);

return json({
darkModeEnabled,
});
};

export default function App() {
const { darkModeEnabled } = useLoaderData<typeof loader>();

return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<header>
<DarkModeToggle isEnabled={darkModeEnabled} />
</header>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}

Stap 2: We bouwen een <DarkModeToggle/> knop die via een prop weet of die aan of uit staat (en zijn icoontje aanpast) en een POST doet naar een endpoint op de server als je er op klikt.

We bouwen een knop dat op basis van een prop (enabled) weet of dark mode aan staat of niet. In het geval dat het aan staat, laten we een ander icoontje zien en zetten we een andere waarde voor de POST request dan wanneer het uit staat.

In Remix zijn er meerdere manieren om data mutaties uit te voeren, waar ik later dieper op inga. Voor nu volstaat het om te zeggen dat https://remix.run/docs/en/v1/api/remix#usefetcher">useFetcher een fetch object teruggeeft dat we kunnen gebruiken om een request te doen zonder dat de pagina automatisch herlaad (alleen als alle JavaScript geladen is). useFetcher heeft veel meer handigheidjes ingebouwd zitten, zoals een check of een request "pending" is of niet.

// ./app/components/DarkModeToggle.tsx
import Icon from '~/components/Icon';
import { useFetcher } from '@remix-run/react';

type Props = {
enabled: boolean | undefined;
};

const DarkModeToggle = ({ enabled }: Props) => {
const fetcher = useFetcher();

return (
<fetcher.Form
action="/api/cookies"
method="post"
>
<input
type="hidden"
name="action"
value={enabled ? 'disable' : 'enable'}
/>

<button type="submit">
<Icon name={enabled ? 'moon' : 'sun'} />
</button>
</fetcher.Form>
);
};

export default DarkModeToggle;

Stap 3: We bouwen de endpoint op de server om de cookie aan te passen met de nieuwe dark mode state.

Op de server maken we een API route aan. In Next.js kan dit door een bestand genaamd cookies.ts aan te maken in de ./pages/api map. Remix geeft je de mogelijkheid om zogenoemde resource routes aan te maken: routes zonder een React component export. Zelf vind ik het altijd fijner om deze resource routes te bundelen in een /api/ folder zodat het duidelijk te zien is dat dit een andere soort route is.

We pakken de form data uit de request, kijken of dark mode aan-, of juist uitgezet is, en passen het cookie aan.

// ./app/routes/api/cookies.ts
import { userPrefs } from '~/cookies';
import type { ActionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';

type ActionData = {
action: 'disable' | 'enable';
};

export const action = async ({ request }: ActionArgs) => {
const cookieHeader = request.headers.get('Cookie');
const formData = await request.formData();

const cookie = (await userPrefs.parse(cookieHeader)) || {};

const { action } = Object.fromEntries(
formData,
) as unknown as ActionData;

if (action === undefined) {
throw new Error('An action is missing');
}

cookie.darkModeEnabled = action === 'enable';

return redirect('/', {
headers: {
'Set-Cookie': await userPrefs.serialize(cookie),
},
});
};

Et voilà. We hebben nu management van de state van de dark mode op onze website, zonder een state management library daarvoor te configureren. Deze state wordt onthouden en kan dus later (opnieuw) uitgelezen worden. In ons voorbeeld is dat via een cookie, maar het kan ook via localStorage of een database op de server.

Data mutaties

Een andere vorm van state management kan worden gedaan op data mutaties. Hierbij wordt data uitgelezen, bewerkt en wederom opgeslagen. Het heeft veel weg van application state, met een paar belangrijke verschillen:

- De waarde van de state is opgeslagen op de server in een data store (zoals een database, in tegenstelling tot een cookie).
- De houdbaarheidsdatum van de state is langer dan een gebruikerssessie. Neem als voorbeeld een blog: de content (zoals de posts) "leven door" als ik klaar ben met het schrijven van een post en het tabblad weer sluit. Of ik op dat moment dark mode aan of uit heb staan (application state) is minder relevant en die state mag zelfs weer vergeten worden.

De mensen van Remix hebben hier nog een uitgebreide post op geschreven die zeker het moeite waard is om te lezen: https://remix.run/blog/remix-data-flow. Ik jat een van de afbeeldingen die zij in hun post hebben geschreven, omdat dit het beste illustreert hoe Remix het state management overneemt als het gaat over data mutaties.

Door gebruik te maken van de loader en action functies, die worden uitgevoerd door respectievelijk een GET en POST request, kunnen we het managen van state verplaatsen van een library naar de server.

Voor ons voorbeeld nemen we een bewerkingsscherm voor een product, waarbij we twee velden hebben die we kunnen bewerken: product naam en prijs. Wanneer je op de knop "Opslaan" klikt, wordt de data meegenomen in de state en krijg je de bewerkingspagina weer te zien met daarin de nieuwe data.

In een traditionele React app zou een state management library ingezet kunnen worden om bij het submitten van het formulier, de ingevoerde data te pakken en de huidige state te verversen. Vervolgens wordt die state opgeslagen in de database en wordt ondertussen de huidige pagina ververst waardoor het bewerkingsscherm de laatste versie van deze data heeft.

Wat we zien in Remix, is dat tijdens het laden van de pagina de product data uit de database wordt gehaald. Vervolgens hebben we een React component dat de data uitleest van de server. Wanneer het formulier wordt verstuurd, wordt de data verwerkt en opgeslagen in de database, en vervolgens wordt de gebruiker weer doorgestuurd naar de bewerkingspagina, dat weer de (geüpdatete) data uit de database leest:

een codevoorbeeld van een product component dat een form bevat, en de loader functie voor de GET requests en action functie voor de POST request

In feite zien we hier de state machine: we "updaten" de state van een product door één van de velden te bewerken en vervolgens op te slaan. Remix voert de action functie uit, stuurt de gebruiker terug naar /products/[product ID]/edit, waar tijdens de page load data wordt uitgelezen uit de database met behulp van de load functie. Remix rendert het Route component dat de data van de server "verwerkt" in de HTML. De data stroomt via één route, de client blijft in sync met de server en je hoeft niet te letten op race conditions.

Ik wil je er ook even op wijzen dat er client-side geen JavaScript draait, en dat de inhoud van het formulier op de server gerenderd wordt. Dat betekent dat het ophalen van de nieuwe data en het zetten van de defaultValue van de twee inputs, gebeurt op de server en niet de client! Het scheelt dus ook dat we geen state management library hoeven te draaien op de client om dit werkend te krijgen.

Next.js biedt hier ook mogelijkheden voor, zowel out-of-the-box als met packages als next-runtime waar ik later op terugkom.

Een diepere blik op Remix

Goed, we hebben nu een aantal voorbeelden gezien waarin full-stack development een steeds grotere rol speelt in ons werk als front-end developer. We hebben ook gezien hoe meta-frameworks zoals Remix ons tonen hoe we bepaalde functionaliteiten of zelfs strategieën kunnen implementeren nu we zelf ook controle hebben over de server.

Ik wil dus even dieper ingaan op Remix zelf. Voor mij heeft Remix een aantal voordelen ten opzichte van Next.js als het gaat om het bouwen van een React project. Veel van de voorbeelden die ik zojuist liet zien maakten gebruik van Remix (ook al zijn ze allemaal ook in Next.js uit te voeren). Er zijn wel wat zaken die ik verder wil uitlichten:

  • Routes, nested routes en resource routes.
  • Wat doen die action's en loader functions nou precies?
  • Foutafhandeling in Remix: ErrorBoundaries en CatchBoundaries.
  • Forms

Routes, nested routes en resource routes

Aan het hart van Remix staan routes. Dat is niet zo vreemd als je bedenkt dat de makers van React Router aan het roer staan bij Remix. De gedachte erachter is dat routes de ruggengraat van een applicatie of website is. En als je dat als uitgangspunt neemt, kan je een aantal aannames doen over het opvragen en binnenhalen van assets en data.

Voor Remix, de routes map is de belangrijkste map die je zult hebben. Alle .jsx of .tsx bestanden vormen uiteindelijk een route die je kan aanroepen via een URL.

Het mooie hier is dat Remix nested routes ondersteunt! Bij een nested route wordt de UI van een applicatie onderverdeeld in URL segments.

Alt: een afbeelding van een factuurdashboard met daarboven een menu dat aangeeft welke delen van een URL worden weergegeven in een interface zoals dit dashboard

In de afbeelding hierboven (gepakt van de website van Remix), zien we dat de route van het <Invoice/> component wordt gerepresenteerd in de URL door het factuurnummer (in dit geval 102000).

Hoe ziet dit er dan uit in het project?

app
├── root.jsx
└── routes
├── ... // andere routes
├── sales
│ ├── ... // andere nested routes
│ ├── index.jsx
│ ├── invoices
│ │ ├── $invoiceId.jsx
│ │ └── index.jsx
│ ├── invoices.jsx
└── sales.jsx

Voor example.com/sales worden 2 routes gerendered: routes/sales.jsx en routes/sales/index.jsx. In sales.jsx kan je de UI voor de "Sales" pagina zetten (in de screenshot kan dat dus de header met "Sales" zijn en het klik menuutje eronder). De sales/index.jsx route laat de standaard UI zien voor example.com/sales, bijvoorbeeld een placeholder of (in dit geval) alles onder het kopje "Overview".

Klikt de gebruiker op "Invoices", veranderd de URL naar example.com/sales/invoices en worden routes/sales/invoices.jsx en routes/sales/invoices/index.jsx geladen. In invoices.jsx kan de lijst met facturen worden geladen, in invoices/index.jsx kan bijvoorbeeld de kop "Kies een factuur uit het linkerrijtje" te zien zijn.

En dan als laatste: wanneer iemand op een factuur klikt. Iedere factuur weet zijn eigen factuur ID dus maken we een dynamische route aan voor het detail overzicht: routes/invoices/$invoiceId.jsx. Deze wordt alleen getoond als de URL een invoice ID heeft, zoals example.com/sales/invoices/102000. De invoiceId wordt in Remix meegegeven aan de loader functie, waarmee je in de database de factuurgegevens kan opvragen.

Het mooie is dat Remix dit niet alleen heel makkelijk maakt, maar de huidige staat van de UI is nu weergegeven in een URL. Dit kan je via een e-mail aan je collega's doorsturen en zij krijgen dan te zien wat jij ook ziet.

Resource routes

Er is nog een type route dat Remix kent, een resource route. Een resource route is niks anders dan een route dat geen React component exporteert, maar wél requests kan behandelen. In Next.js zouden dit de routes zijn die je in de /pages/api/ map plaatst. Je kan hiermee routes bepalen die alleen iets inladen of een actie uitvoeren. In het dark mode voorbeeld hierboven gebruik ik een resource route om de cookies van mijn website te updaten, maar je kan het ook gebruiken om bijvoorbeeld een PDF te genereren aan de hand van dynamische data.

Op mijn blog gebruik ik Plausible voor mijn analytics, en om adblockers te ontwijken laad ik het Plausible script niet rechtstreeks in mijn pagina in. Ik gebruik een resource route om het Plausible script te proxiën naar mijn pagina.

action en loader

In het voorbeeld van de factuur dashboard zei ik dat de routes/invoices/$invoiceId.jsx een loader functie gebruikt om factuurgegevens op te vragen. Remix gebruikt loader en action functies voor data mutaties in je route. Deze zijn overigens niet verplicht voor een specifieke route, en je mag ook één van de twee gebruiken.

In het kort: loader functies draaien wanneer er data wordt opgevraagd (een GET request), en action functies draaien wanneer er iets met de data gedaan wordt (een POST, PUT of DELETE request).

Beide functies retourneren een response object. Deze heeft de vorm van het standaard Response object dat bij fetch gebruikt wordt. Hier zie je dus goed dat Remix zich houdt aan web standaarden: een request object implementeert bijvoorbeeld de Request interface van fetch.

Door middel van het response object kan je Remix laten weten dat er een antwoord is met een bepaalde status (zoals 201 als je iets hebt aangemaakt in de database), of je kan de gebruiker redirecten naar een andere pagina (met een HTTP status code van 302). Remix geeft je de json() en redirect() functies voor het gemak, want deze zijn niks anders dan wrappers om een Response object heen.

Wat wél leuk is om te weten, is dat je een response ook kan throw'en!

Foutafhandeling in Remix: ErrorBoundaries en CatchBoundaries

Remix geeft je twee opties voor foutafhandeling: een ErrorBoundary en een CatchBoundary. In het kort gebruik je de ErrorBoundary voor fouten die je niet anticipeert (zoals 500 server errors) en CatchBoundaries voor fouten die je anticipeert óf die de flow van je applicatie niet stopzetten (zoals 400 errors).

Het idee van het Remix-team is dat je default export, het React component dus, je happy path voorstelt. Voor de unhappy path kan je je logica verplaatsen naar een ErrorBoundary of CatchBoundary.

Beide boundaries kan je definiëren in de root.tsx, dat als basis voor iedere pagina gebruikt wordt. Maar de verborgen kracht is dat Remix ook boundaries op routeniveau ondersteunt. Met andere woorden, als er iets fout gaat in je route (of zelfs nested route!) wordt die ErrorBoundary of CatchBoundary getoond.

Terug naar het factuur dashboard, waar we op de URL example.com/sales/invoices een lijst met facturen hadden. Wat nou als je een factuur hebt aangeklikt van een factuur die inmiddels is verwijderd? Bijvoorbeeld omdat je die URL hebt doorgestuurd naar een collega en die kijkt een paar weken later in het systeem.

Stel, de code voor routes/invoices/$invoiceId.jsx is als volgt:

export const loader = async ({ params }) => {
// "$invoiceId.jsx" wordt omgezet naar een 'invoiceId'
// property op het 'params' object.
const { invoiceId } = params;

const invoice = await db.invoices.find({id: invoiceId});

return json({ invoice });
};

export default function InvoiceDetails() {
const { invoice } = useLoaderData();

return (
<Invoice details={invoice} />
);
}

Wanneer een factuur wordt opgevraagd dat niet meer bestaat, gaat de regel const invoice = await db.invoices.find({id: invoiceId}); verkeerd: we krijgen dan geen invoice terug, terwijl ons React component daar wél van uitgaat. Wat we kunnen doen is in ons React component het scenario behandelen dat invoice niet aanwezig is.

Wat we ook kunnen doen is een CatchBoundary inzetten voor een 404 fout!

We kunnen de code voor routes/invoices/$invoiceId.jsx zo aanpassen:

export const loader = async ({ params }) => {
const { invoiceId } = params;

const invoice = await db.invoices.find({id: invoiceId});

if (!invoice) {
throw new Response('missing invoice', {
status: 404,
});
}

return json({ invoice });
};

export default function InvoiceDetails() {
// ...
}

export function CatchBoundary() {
// 'caught' bevat nu de response object informatie
// Het is eigenlijk hetzelfde als useLoaderData
const caught = useCatch();

const params = useParams();

return (
<h1>Sorry</h1>

<p>We konden geen factuur vinden met nummer { params.invoiceId }</p>
);
}

Nou, als je collega een paar weken later in het systeem kijkt voor de inmiddels verwijderde factuur, krijgt die een foutmelding te zien. In het React component dat we als default export zetten hoeven we alleen rekening te houden met het happy path.

We kunnen dit ook inzetten voor fouten die we niet (kunnen) anticiperen. Dus als React component, de loader of een action functie een fout opgooit (of een Response met een error status code van 500), dan wordt de ErrorBoundary getoond:

export const loader = async ({ params }) => {
// Dus als we hier een throw doen:
throw new Error('Something went wrong!');
};

export default function InvoiceDetails() {
// Of hier:
throw new Error('Something went wrong!');
}

export function CatchBoundary() {
// Dan wordt deze boundary NIET getoond
}

export function ErrorBoundary({ error }) {
// Maar deze wel!

// Pas op, geen 'use' hook hier. We krijgen de error
// doorgestuurd als prop.
console.error(error);

return (
<h1>Oeps...</h1>

<p>Er is iets fout gegaan. Probeer het later opnieuw.</p>
);
}

Als we dit allemaal onder elkaar zetten hebben we een happy en unhappy path voor een route, allemaal in 1 bestand!

Dit is voornamelijk handig wanneer je gebruikt maakt van een CRUD-flow, waarbij een gebruiker ook data kan aanleveren of bewerken.

Forms

Naast routes zijn forms ook een groot deel van een Remix applicatie. In de meest simpele vorm kan je een form en de afhandeling ervan als volgt bouwen:

export default function Form() {
return (
<form method="post">
<label for="username">Gebruikersnaam</label>
<input type="text" name="username" id="username" />

<label for="password">Wachtwoord</label>
<input type="password" name="password" id="password" />

<button type="submit">Inloggen</button>
</form>
);
}

export const action = async ({ request }) => {
const body = await request.json();

const accessGranted = checkUserCredentials(body);

if (accessGranted) {
return redirect('/profile');
}

return json({ unauthorized: true }, { status: 403 });
};

Het leuke hiervan is dat dit werkt zonder JavaScript. Ik zei al eerder dat Remix je wel eens lichtjes in de richting van progressive enhancement duwt, dit is zo'n voorbeeld. Remix voert de volgende stappen uit wanneer iemand op "Inloggen" klikt:

  • De browser serialiseert de data van het formulier in de body van een POST request;
  • De browser navigeert de gebruiker naar de endpoint van de form (in dit geval is het dezelfde URL, maar met <form action="/insert-endpoint-here" kan je dit veranderen);
  • De server verwerkt de request en stuurt een redirect naar de browser (in dit geval wederom naar dezelfde URL, maar dit kan je veranderen);
  • De browser herlaadt de pagina;

Het is ook mogelijk om via onsubmit een fetch handler in te zetten zodat je het formulier kan verzenden en de nieuwe data in kan laden zónder page reload, zodra JavaScript is ingeladen. Maar Remix vangt dit al voor je af met hun <Form/> component.

Dit is een drop-in vervanging voor <form/>, werkt ook hetzelfde als er geen JavaScript is ingeladen, en doet een request zonder page load als JavaScript wél is ingeladen:

export default function Form() {
return (
<form method="post">
<label for="username">Gebruikersnaam</label>
<input type="text" name="username" id="username" />

<label for="password">Wachtwoord</label>
<input type="password" name="password" id="password" />

<button type="submit">Inloggen</button>
</form>
);
}

Maar wat nou als we willen dat de verzend knop niet klikbaar is terwijl de gebruiker wil inloggen (en we dus maar één request naar de server per keer sturen)?

Gebruik useTransition:

export default function Form() {
const transistion = useTransition();

return (
<form method="post">
<label for="username">Gebruikersnaam</label>
<input type="text" name="username" id="username" />

<label for="password">Wachtwoord</label>
<input type="password" name="password" id="password" />

{ // We hebben hier maar een component maar van gemaakt }
<Button
showSpinner={ transition.state === 'submitting' }
>
Inloggen
</button>
</form>
);
}

export const action = async ({ request }) => {
const body = await request.json();

const accessGranted = checkUserCredentials(body);

if (accessGranted) {
return redirect('/profile');
}

return json({ unauthorized: true }, { status: 403 });
};

Okee, maar niet alle formulieren zijn in essentie ook een navigatie actie? Denk aan een formulier om je op te geven voor een nieuwsbrief dat op alle pagina's van je website wordt getoond? Of een dark mode toggle voor je blog?

Voor deze zaken heeft Remix useFetcher. Deze geeft je een fetch-achtig object terug dat voor jou een loader of action functie kan aanroepen zonder dat de URL veranderd.

Een simpel voorbeeld van een scenario met useFetcher wijs ik je naar mijn eerdere code voor het dark mode toggle, of lees de Remix documentatie voor useFetcher eens door.

Optimistic UI

Dit is een mooi kopje waar ik even in wil duiken. Remix maakt het mogelijk om het "optimistic UI" pattern in te zetten. Hierbij pak je een formulier voor bijvoorbeeld het maken of bewerken van een item of product, en wanneer het formulier wordt verstuurd laat je de server de data van dit product of item bewerken en opslaan in een database. Ondertussen laat jij in je UI "alvast" de pagina zien waarbij je de gegevens van het item of product weergeeft aan je gebruiker. Komt er ergens een foutmelding voorbij? Dan tonen we die, anders gaan we ervan uit dat de opsla-actie lukt en zouden we de gebruiker toch naar deze detailpagina doorsturen.

In het kort: we gaan ervan uit dat de opsla- of bewerkingsactie lukt en laten alvast zien waar je gaat eindigen.

De documentatie van Remix legt dit concept haarfijn uit, beter dan ik het zou kunnen.

Maar, hoe zit het dan met Next.js 13?

Een paar weken geleden kwam de Layouts RFC uit van Next.js, waarin ze een plan presenteerden om naast de pages/ folder nu ook een app/ folder te ondersteunen. Binnen deze folder kon je dan nested routes, shared layouts en React server components gebruiken. Veel ideeën voelde bekend aan als je Remix had gebruikt, en ik heb het idee dat Vercel, het bedrijf achter Next.js, ook zeker goed had gekeken naar de manier waarom Remix werkte.

Dus, nu Next.js 13 uit is, wat is dan nog de meerwaarde van Remix?

Persoonlijk is voelt mij Remix nog steeds het fijnste als framework. Remix geeft mij het gevoel alsof ik mét het web platform, in plaats van óm het platform heen werk. Ik kan dit het beste uitleggen met wat voorbeelden.

Progressive enhancement

Dit is voor mij nog steeds een van de hoofdpunten: met Remix werk je makkelijker met progressive enhancement. De discussie over "JavaScript enabled or disabled" is nog steeds gaande, en het is ook niet iets waar ik nu een standpunt voor inneem. Wat ik wél merk, is dat het niet zo'n raar idee is dat je JavaScript niet wordt geladen of juist zo ontzettend traag dat je niet aan de slag kan. Enkele voorbeelden van scenario's die ik mee heb gemaakt waarin een gemiddelde JavaScript bundel niet of nauwelijks werd geladen waren tijdens een weekje op Center Parcs (zeker als meerdere mensen een laptop mee hebben), of als je in Eindhoven op het terras zit tijdens een thuiswedstrijd van PSV of een concert van Guus Meeuwis.

In dat geval is het "succes ermee!"

Het is niet zo dat ik met Remix nu ineens geen JavaScript verscheep naar de client, zeker wel. Maar ik merk dat ik, door het off-loaden van logica naar de server of de manier hoe data mutaties worden gedaan door forms, ik minder vaak JavaScript "nodig" heb in de client. De grootste boosdoener is voor mij, hilarisch genoeg, de https://www.npmjs.com/package/classnames">classnames package van npm.

En het is niet dat Next.js het je zo moeilijk maakt, er is een stukje geschreven in hun documentatie. Het leest wel minder zelfverzekerd dan de guide in de documentatie van Remix.

Sterker nog, in de (naar mijn mening uitstekende) tutorial van Remix laten ze je pas client-side JavaScript toevoegen aan het einde ervan. Dat is wel lef hebben als je het mij vraagt.

SSR vs SSG

Next.js ondersteund al sinds jaar en dag server-side rendering (SSR) en static site generation (SSG). Sinds een tijdje zit daar ook ISR (incremental static regeneration) bij, maar wat is nou het verschil tussen die drie?

Server-side rendering (SSR)

Bij SSR wordt de pagina op de server gerenderd en wordt de HTML naar de browser gestuurd. De browser download ondertussen ook de JavaScript bundel en voert die vervolgens uit. Als dat gebeurd, worden de dynamische elementen op de pagina geladen en kan de gebruiker interacteren met wat er op het scherm staat. Dat laatste heet hydration.

Static site generation

Bij SSG worden de HTML pagina's tijdens het bouwen van de site opgebouwd. Als je een framework als Next.js (of Gatsby, bijvoorbeeld) naar een database wijst met blog posts, zal er tijdens het bouwen van de site van iedere blog post een HTML pagina worden gemaakt. Dit wordt allemaal op de server gezet, maar als er een blogpost bij komt in de database (of gewijzigd wordt) moet de hele site opnieuw gebouwd worden.

Incremental static regeneration

Om dat laatste op te lossen heeft Next.js ISR ingebouwd, waarbij een (gedeeltelijke) rebuild van de site wordt gedaan als de data gerevalideerd wordt. Dit kan op basis van tijd, zoals "iedere 60 seconden", of on-demand. In Next.js kan je een API route maken die Next.js verteld alles te revalidaten als je die endpoint raakt, bijvoorbeeld met een webhook.

Remix ondersteunt alleen SSR.

Static site generation (daar groepeer ik vanaf nu ook ISR onder), kwam vooral op in de tijd dat servers minder krachtig waren én ook niet al te goedkoop zijn. Voor websites met een zeg maar semi-dynamische content, zoals een blog, scheelde het geld als je die kon hosten op een CDN. Netlify heeft bijvoorbeeld een gulle free tier, dus je kan makkelijk een blog draaien zonder al te veel extra kosten.

Tegenwoordig zijn servers niet alleen krachtiger en goedkoper geworden, de infrastructuur is ook een stuk beter. Het is tegenwoordig niet zo lastig meer om een netwerk van servers in te zetten als CDN. Hosting providers als DigitalOcean maken het makkelijk om meerdere servers ("droplets") in meerdere regionen in te zetten, en Fly.io heeft zelfs documentatie geschreven over het scalen in meerdere regio's. Fly.io wordt trouwens meestal aangeraden door het team van Remix zelf, omdat vinden dat een Remix site met correcte caching headers en gehost in meerdere regionen op Fly.io, dezelfde performance geeft als een site gemaakt met SSG.

Of dat zo is, laat ik als een oefening voor de lezer.

Persoonlijk vind ik het juist fijn dat Remix alleen SSR ondersteund. Ik kan de flow makkelijk in mijn hoofd kwijt: eerst server rendering, dan client hydration. In feite zorg ik ervoor dat mijn routes en components werken op de server en makkelijk gehydrateerd kunnen worden. Browser API's doe ik in useEffect, maar dit komt niet eens zo vaak voor.

Bij Next.js projecten moet ik wel eens nadenken of ik kies voor één van de drie strategieën, en SSG/ISR brengen stiekem best wat complexiteit met zich mee. Je moet toch (op zijn een minst simpele vorm van) een build straat aanleggen dat verbonden is met je productie database en het maken van een endpoint om een incremental static regeneration af te vuren is altijd wat werk. Next.js maakt het trouwens in versie 13 wat makkelijk door een nieuwe API. Deze werkt trouwens alleen in de app/ folder.

Forms

Forms is iets dat vak genoemd wordt bij Remix pitches, zeker als het in vergelijking gaat met Next.js. Ik snap het wel, form handling gaat ook ontzettend goed in Remix en is, in mijn ogen, hetgeen dat 100% de visie en gedachtegoed van Remix "ademt".

Nu is het in Next.js uiteraard mogelijk om met forms te werken, en behandelen ze zelfs het gebruik van forms zonder JavaScript. Maar in Remix voelt het allemaal meer vertrouwd, meer zoals het altijd zou moeten werken. Geen onSubmit's en event.preventDefault()'s meer, geen handmatige fetch requests meer zodat je onmiddellijk het resultaat van een POST kan laten zien.

In Next.js is dit stuk wel veel verbeterd. Er is een hoofdstuk over data mutaties opgenomen in de nieuwe beta documentatie van Next.js, en die voelt al meer vertrouwd. Belangrijk om te weten: op deze pagina is laat het team achter Next.js weten dat er een nieuwe RFC komt over data mutaties!

In de pages/ folder is dit allemaal niet ondersteunt helaas. Om er op een (voor mij) logische manier mee te werken heb ik https://www.npmjs.com/package/next-runtime">next-runtime gebruikt in mijn Next.js projecten, waarbij ik POST requests binnen Next.js' getServerSideProps kan verwerken.

Hoe zit het aan het einde van de dag?

Nou, aan het einde van de dag ben ik ontzettend onder de indruk van Next.js 13. Ik ga er zeker induiken en ik ben ontzettend benieuwd naar de updates die er nog gaan komen.

Maar voor mij blijft Remix op nummer één staan. Voor mij "klopt het" gewoon, en kan ik ontzettend snel iets neerzetten. De starters kits zijn belachelijk uitgebreid en production-ready, en de Jokes App tutorial is één van de beste tutorials die ik heb gevolgd. Alles klikte toen ik die deed, en ik kan het je zeker aanraden.

Maar wat het belangrijkste is, van zowel Next.js als Remix: ze geven ons front-enders méér tools om ons werk te kunnen doen. Ze bieden meer ruimte en flexibiliteit in ons werk door de server mee te nemen als onderdeel van onze, tjsah, laat ik "runtime environment" zeggen. We opereren niet meer op de zwarte doos dat "de browser van je gebruiker" heet, maar geeft je een extra, betrouwbaar platform dat onderdeel is van je applicatie.

Het maakt ook bepaalde zaken zoveel makkelijker. We hoeven niet meer te wachten op een back-end developer die voor ons een proxy server moet bouwen zodat we een authenticatie token kunnen verbergen én CORS-fouten kunnen vermelden. We bouwen de proxy nu gewoon zelf.

Het maakt de back-end (developer) niet overbodig, het evolueert ons allemaal naar een volledige technologie stack.

Het evolueert ons allemaal naar full-stack.

Je eerste keer als spreker

Misschien ken je het wel: Je zit in de zaal bij de Fronteers conferentie, ziet een spreker op het podium staan en denkt "Hey, dat is misschien ook wel eens leuk om te doen". Of je ziet iemand uit je kring op het overzicht van sprekers staan van een conferentie in New York of Madrid en denkt "wow! Dat is tof! Dat wil ik ook." Maar ja, hoe pak je dat aan, beginnen als spreker? Want is dat niet lastig? Waar moet ik het over hebben? En hoe maak ik zulke briljante slides en kom ik toch op de proppen met zulke verhalen?

Het lijkt soms best moeilijk. En geloof me, je kan het ook best moeilijk voor jezelf maken. Maar met jaren van ervaring met het spreken op conferenties kan ik zeggen: Je doet het vooral zelf. Het moeilijk maken dus. Want in de basis is het spreken op een conferentie niet heel veel anders als het uitleggen van een concept aan je collega, het presenteren van een design aan een klant of enthousiast vertellen over je hobby aan je beste vriend. Het enige verschil is dat podium.

Maar waar begin ik dan?

Nou ja, je begint bij het begin. En dat begin is eigenlijk: Bedenken waar je iets over wil vertellen. Dit hoeft niet per se iets te zijn waar je heel veel van weet, maar als je er al het nodige van weet maakt het alles wel makkelijker. Om echt te bepalen waar je het over wil hebben is het goed om eerst voor jezelf een lijst te maken van onderwerpen waar je interesse in hebt, waar je ook tegen een collega uitgebreid over kan vertellen en/of waar je collega's jou regelmatig vragen over stellen. Als je een lijstje hebt van een paar onderwerpen kies je degene die je het leukst vindt, of waarvan je zoiets hebt "daar zie je maar weinig presentaties over". Hou er rekening mee dat je, afhankelijk van de conferentie, wel 30-45 minuten over het onderwerp moet kunnen praten. Het moet dus niet te specifiek zijn.

Staar je hier overigens niet op blind, want tijdens de ontwikkeling van je presentatie kan het best zijn dat je toch nog wel iets gaat schuiven met je onderwerp.

Een onderwerp, en nu?

Als je eenmaal een onderwerp hebt, dan ga je dat uitdiepen. Dit kan je bijvoorbeeld doen door een mindmap te maken, of een lijst met steekwoorden op te schrijven. Het kan ook helpen om een collega of vriend te vragen of je ze even mag uitleggen over dat onderwerp. Zodra je er op die manier over gaat nadenken kom je vanzelf op de belangrijke kernpunten van je uitleg. En het zal geen verrassing zijn dat die kernpunten dan ook de basis van je presentatie zullen zijn. Als je die basis hebt, dan kan je het verder gaan uitwerken. Wat mij soms helpt is om over het onderwerp een blogpost of zelfs een serie blogposts te schrijven. Tijdens het schrijven daarvan merk je vanzelf welke structuur, volgorde en stijl het beste werkt voor dat onderwerp. Je kan die blogposts direct publiceren, maar ook nog even bewaren voor later.

Als je basis staat en je hebt een gevoel voor de structuur dan kan je ook gaan werken aan je slides. Zoek een tool die prettig voor je werkt. Voor sommigen zijn dat traditionele tools als Powerpoint of Keynote, anderen vinden het heel prettig om te werken met revealjs, impress of bijvoorbeeld een tool als Deckset, of misschien zelfs Prezi. Doe wat onderzoek en kies de tool die je het prettigst lijkt. Uiteindelijk gaat het om de inhoud, en niet de tool die je ervoor gebruikt. Zo lang het maar prettig voor je werkt.

Verdeel nu eerst de kernonderwerpen over een aantal slides zodat de structuur van je slidedeck zichzelf al een beetje laat zien. Nu kan je de presentatie verder invullen. Daarbij kan je een aantal do's en don't goed ter harte nemen:

Font

Gebruik ten allen tijden een groot font. Zeker in een grote zaal moeten de mensen achterin ook iets kunnen lezen. Gebruik daarbij ook een duidelijk leesbaar font. Mensen moeten in 1 oogopslag kunnen zien wat er op je slide staat. En als je dan denkt "maar dan kan ik niet alles kwijt wat ik kwijt wil", verwijs ik je door naar mijn volgende punt.

Hou het kort

Gebruik geen lange zinnen. Mensen zijn geneigd de zinnen te gaan lezen en terwijl ze lezen ben je ze kwijt als luisteraar. Mensen hebben namelijk moeite met multitasken. Terwijl zij de zin op je slide lezen luisteren ze niet, en tegen de tijd dat ze klaar zijn met lezen weten ze niet meer waar je het nou precies over hebt.

Bulletpoints

Gebruik geen lijstjes met bulletpoints, of minimaliseer het. In veel gevallen heeft het weinig meerwaarde om zo'n lijstje te maken, want je bespreekt (hopelijk!) maar 1 bulletpoint tegelijk. In plaats van een bulletpoint kan je ook gewoon een slide per onderwerp gebruiken, met op iedere slide 1 of 2 woorden (die je anders na de bulletpoint zou zetten). Door een slide per onderwerp te maken hou je focus, zowel in jouw verhaal als ook de focus van je luisteraar.

Mocht je toch ergens een reden hebben voor bulletpoints, zorg er dan voor dat ze niet allemaal tegelijk op het scherm verschijnen, maar pas zodra je klikt omdat je naar de volgende bulletpoint gaat. Dit wederom in verband met het risico dat mensen je hele lijstje in 1 keer gaan lezen, en dan ben je ze dus kwijt.

Ondersteuning

Je slides zijn er ter ondersteuning van je verhaal, niet voor de mensen die niet bij je presentatie waren. Zorg ervoor dat ze je verhaal dan ook ondersteunen. Zoek bijvoorbeeld een plaatje die goed past bij waar je het over hebt, en combineer dat plaatje met dat ene woord (OK, of misschien 2 woorden) waar je het over hebt. Een aanrader over dit onderwerp is het boek Slide:ology van Nancy Duarte.

Speaker notes

Als je presentatietool het toestaat, schrijf in de speaker notes per slide de kernonderwerpen waar je het over wil hebben. Dit kunnen kernwoorden zijn, maar bijvoorbeeld ook belangrijke referenties zoals namen van schrijvers, boeken of blogposts die je zeker niet wilt vergeten of verkeerd wil uitspreken. De speaker notes zie je op je eigen scherm als je in presentatiemodus bent en de slides op het grote scherm te zijn zijn. Zie het als spiekbriefjes.

Wil je liever niet telkens op je scherm kijken, dan kan je ook overwegen om indexcards te gebruiken die je in je hand kan houden.

Help! Ik heb een presentatie!

Nu je een presentatie hebt wil je dat natuurlijk graag aan de wereld laten weten. Het is verleidelijk om direct conferenties aan te schrijven. Sommige conferenties werken op uitnodigingsbasis, anderen hebben van te voren een manier om je presentatie aan te melden zodat ze weten dat je die beschikbaar hebt. Maak een lijstje met conferenties waar je graag wil spreken en zorg dat je weet hoe je daar binnen kan komen.

Maar voordat je daadwerkelijk over gaat tot het benaderen van conferenties is het goed om te oefenen. Het klinkt misschien gek, maar thuis voor de spiegel is een goede eerste oefening. Zet je huisdier, een pluche beest, je huisgenoot of partner tegenover je zodat je het gevoel hebt dat je tegen iemand praat, en probeer je presentatie uit. Je merkt dan snel genoeg waar je misschien nog wat moet tweaken of zelfs alsnog de volgorde moet aanpassen omdat het dan beter werkt. Hou ook bij hoe lang je doet over je presentatie, want conferenties willen graag dat je presentatie netjes binnen hun schema past. Te kort of te lang is vervelend voor de organisatie van een conferentie.

Als je op deze manier wat geoefend hebt is de volgende stap om eens te kijken naar een klein gezelschap. Een gebruikersgroep of een groep collega's zijn vaak prima manieren om eens voor een groep te spreken en te wennen aan het idee dat er meerdere mensen naar je kijken terwijl je iets vertelt. Vraag ze om feedback voor verbetering. Je presentatie is een levend iets. Het is nooit af, je blijft er altijd aan werken, zelfs als je de presentatie op meerdere conferenties geeft.

En dan is het tijd om toch echt eens conferenties te benaderen. Begin bij de conferenties met een open call for proposals of call for papers. Hiervoor hebt je een goede, korte omschrijving nodig en een pakkende titel. Ook hier: Vraag feedback van vrienden en collega's, of check even met een ervaren spreker. De titel en omschrijving zijn vaak de basis waarop een conferentie je presentatie selecteert (of niet!) dus het moet vooral een beetje prikkelen.

Wees voorbereid op teleurstelling: Veel conferenties krijgen een veelvoud van het aantal open plekken in het schema aan voorstellen. Zo kan een conferentie met 7 plekken in het schema soms wel 100 voorstellen krijgen. De kans bestaat, zeker in het begin, dat je niet gekozen wordt.

Maar dit is het punt waar ik even terug wil komen op die blogposts. Nu je graag door een conferentie geselecteerd wil worden is het een goed moment om te laten zien dat jij echt wel iets weet van dit onderwerp. Publiceer je blogpost of serie blogposts over je onderwerp. Als het formulier van de conferentie een veld heeft met "opmerkingen voor de organisatie", stuur vooral de links naar je blogposts mee om te laten zien dat je echt veel weet van dit onderwerp. Dit aakt de kans dat je geaccepteerd wordt een stuk groter.

You've been selected

En dan valt het mailtje in je mailbox. Een uitnodiging van een conferentie om te komen spreken. Nu gaat het er op aan komen. En goede voorbereiding is het halve werk. Zorg dus voor:

  • Een goede laptop met je presentatiesoftware
  • Eventuele adapters voor VGA (ja echt!) en HDMI
  • Een USB stick met je presentatie als backup, met ook een PDF-versie van je slides
  • Een backup van je presentatie alsmede een PDF-versie van je slides, ergens in "de cloud"

Zorg dat je op de conferentie altijd ruim op tijd aanwezig bent op de plek van je presentatie, zodat je op tijd je laptop kan aansluiten. Mocht er iets mis zijn dan heb je nog genoeg tijd om het te fixen.

En ik kan wel zeggen "wees niet nerveus" maar ik weet dat dat niet werkt. Zeker de eerste keer zal je nerveus zijn. Ga dus voor je naar de ruimte gaat eerst nog even naar het toilet.

Kijk die mensen dan

Daar sta je. Je gaat beginnen. Spreek rustig en duidelijk, en doe je verhaal. Je hebt dit voorbereid, je kent het verhaal, je hebt je index cards of je speaker notes, het komt goed. Iedereen in die zaal is daar om jou te zien shinen. Niemand is daar om je te zien mislukken. Een foutje kan. Een verspreking kan. Niets aan de hand. Doe je ding. Even de draad kwijt? Neem adem, neem een slok water, kom tot zinnen, check je speaker notes en ga verder. Het komt allemaal goed. Kijk tijdens het presenteren een beetje rond. Kijk niet 1 persoon aan, maar kijk gewoon een beetje rond. Je hoeft niet eens mensen recht aan te kijken, maar als je wat rondkijkt heeft iedereen het gevoel aangesproken te worden.

Als er aan het einde ruimte is voor vragen, wees daar ook niet bang voor. Je kent dit onderwerp, dus die vragen komen ook wel goed. En mocht je het antwoord niet weten, dan is dat ook geen probleem. Je kan vragen of iemand anders in de zaal het toevallig weet, of aanbieden om na afloop even samen met de vraagsteller het op te zoeken.

Zie je wel? Je kon het! Je hebt het gedaan. En als je een beetje bent zoals ik, dan ben je nu verslaafd en ga je nog veel vaker spreken.

Na de presentatie

Na de presentatie en het applaus komen er ongetwijfeld mensen op je af. Maar tenzij je de laatste spreker van de dag bent hebt je op dat moment nog wel een taak: Zorgen dat je spullen snel weg zijn zodat de volgorde spreker kan opbouwen. Dus ruim eerst even je spullen op, en mensen met vragen wijs je even de weg naar de deur, om even met je mee te lopen. Buiten de ruimte kan je rustig de tijd nemen om nog wat meer vragen te beantwoorden, maar dan kan het programma gewoon verder en hou je niemand op.

Het is wel lastig hoor!

Ik hoor het je al zeggen. "Ik vind dit allemaal wel lastig hoor". Geen zorgen. Veel ervaren sprekers zijn bereid om nieuwe sprekers te helpen. Benader dus eens een spreker als je twijfelt. De meesten zullen je enthousiast hun anekdotes vertellen, en je helpen om je presentatie tot een succes te maken.

Tot snel op een conferentie!

Gezellig, een spelletje

Je zit met de hele familie onder de kerstboom en de veilige gespreksonderwerpen raken uitgeput. Om het gezellig te houden stel je voor om een spelletje te doen. De meesten hebben wel zin in Scrabble, maar je kan die doos nergens vinden! Geen paniek, je bouwt gewoon je eigen spel. Met HTML en CSS timmer je dat bord zo in elkaar.

Bekijk het bord in real life action.

CSS Grid Layout is een van de beste toevoegingen aan CSS van de laatste jaren. Kort gezegd definieer je daarmee een raster of grid van rijen en kolommen waarop je items plaatst, en die items kunnen meerdere rijen en kolommen overspannen. Het grid zelf kan volledig vastliggen of flexibel zijn. Zowel het aantal rijen en kolommen als de afmetingen van die rijen en kolommen kun je laten afhangen van de beschikbare ruimte.

Een van de grote voordelen is dat je dingen zo lekker makkelijk kunt stapelen. Niks position: relative en position: absolute. Plaats items gewoon in dezelfde kolom en rij. Posities schalen probleemloos mee met de container zonder gedoe met offsets.

Bij een CSS-grid heb je altijd een parentrelement en childelementen nodig. De parent is de container die de definitie van het grid bevat en de children zijn de items die in het grid komen te staan. Zonder children heb je dus geen zichtbare content in het grid.

Een scrabblebord met daarop de woorden Fijne feestdagen, gelukkig nieuwjaar gespeld

Een grid voor het spelbord

Het ontwerp van een spelbord is een grid en bij Scrabble zie je dat heel duidelijk. Het bord bevat 225 vakjes verdeeld over 15 rijen en 15 kolommen. Je hebt dus 15 x 15 = 225 items nodig om het bord een gezicht te geven. Dat zijn 225 span-elementen waarvan de meeste een grijze achtergrond hebben. Een deel heeft een data-attribuut dat de bonus aangeeft, bijvoorbeeld `data-bonus="3 x woord waarde".

Zo ziet de gridefinitie eruit:

.speelbord {
display: grid;
grid-template-columns: repeat(15, 4.25rem);
grid-template-rows: repeat(15, 4.25rem);
}

Het aantal span-elementen is gelijk aan het aantal rasterplaatsen (cellen). Daardoor is het niet nodig om elk item expliciet op zijn plek te zetten. De items lopen in volgorde van de HTML vanzelf het raster in vanaf rij 1, kolom 1 naar rij 15, kolom 15. Je moet wel zelf uittellen welke items een data-attribuut moeten hebben.

Een oplossing voor de letters

Ook de letters zijn griditems en moeten dus net als de bordvakjes in een gridcontainer staan. Het verschil is dat de letters een specifieke positie in het raster hebben. Ze kunnen dus sowieso niet automatisch het raster inlopen.

Je kúnt de letters onder het laatste span-element van het spelbord zetten. Daarmee voeg je ze toe aan het speelbordgrid. Maar zodra je een letter in het grid positioneert, valt het speelbord uit elkaar. De gepositioneerde letter neemt namelijk de plek in van een bordvakje dat geen vaste plek heeft. Daardoor schuift dat bordvakje een positie op en dat doen ook alle vakjes daarna.

Je hebt de keus uit twee oplossingen:

  1. - Geef alle 225 bordvakjes een positie in het grid met grid-area: 1 / 1; grid-area: 1 / 2; tot en met grid-area: 15 / 15.
  2. - Maak een tweede gridcontainer voor de letters, met precies dezelfde eigenschappen als het speelbord. Dit grid leg je boven op het speelbordraster. Nu hoef je alleen de letteritems op hun plek te zetten.

De eerste oplossing is een krankzinnig monnikenwerk en alleen aan te raden als je de feestdagen eigenlijk liever alleen doorbrengt.

Oplossing twee is veel minder werk en overzichtelijk, met alle letters bij elkaar in hun eigen container.

De grids moeten natuurlijk precies op elkaar liggen. Met een extra grid is dat geen enkel probleem. Zet het .speelbord en het .lettergrid samen in een nieuwe container met een griddefinitie voor één rij en één kolom (dus één cel):

.buitengrid {
display: grid;
grid-template-columns: minmax(320px, 1fr);
grid-template-rows: minmax(320px, 1fr);
margin: 2px;
max-inline-size: 1020px;
}

Deze ene cel is responsive. De minimale maat is 320px en daarboven krijgt de cel zoveel ruimte als er is. De eenheid fr (fraction) is speciaal voor de gridlay-out toegevoegd aan CSS. Een fr staat voor een evenredig deel van de beschikbare ruimte. Voorbeeld: twee kolommen van elk 1fr krijgen elk de helft van de beschikbare ruimte. Zijn ze elk 2fr, dan krijgen ze ook elk de helft. Is de instelling 2fr en 1fr, dan krijgen ze respectievelijk twee derde en een derde. Op zo’n manier maak je elke gewenste verhouding.

Om ervoor te zorgen dat de tekst en de vakjes responsive zijn, is op het rootelement een tekstgrootte op basis van de viewport ingesteld. Het font is 1.55 maal de kleinste viewportafmeting, dat kan dus de breedte of de hoogte zijn. Op een iPhone SE (375 x 667) is het font 375/100 x 1.55 = 5.8px. Dat leest niet echt lekker, maar ja, je moet wel die 15 vakjes kwijt. Een 4K-televisie is een betere keus, met als kleinste afmeting 2160px en een fontgrootte van 33.46px.

:root {
font-size: 1.55vmin;
}

Vervolgens plaats je het bordraster en het letterraster allebei in rij 1, kolom 1:

.speelbord, .lettergrid {
grid-area: 1 / 1;
}

De eigenschap grid-area is een korte notatie voor grid-row-start, grid-column-start, grid-row-end en grid-column-end. Laat je de eindwaarde weg, dan is het item vanzelf 1 rij bij 1 kolom. Je kunt ook schrijven grid-area: 1 / 1 / 2 / 2. (Gebruik grid-area ook om items in gebieden met een naam te plaatsen.)

Voor de stapelvolgorde van het bord en de letters geldt de normale volgorderegel: later in de HTML ligt hoger op de stapel. Als het nodig is kun je de volgorde met z-index eenvoudig aanpassen. In de grid-context verander je met z-index de stapelvolgorde zonder dat je position op iets anders dan static hoeft in te stellen.

De speciale bordvakjes

Met een algemene childselector op het spelbord krijgen alle speelvakjes dezelfde basisopmaak. Die vakjes zijn 4rem groot, terwijl de rastercellen 4.25rem zijn. Door de vakjes te centreren in de cel blijft er zo rondom wat witruimte tussen de vakjes.

.speelbord > * {
align-items: center;
background-color: lightgrey;
block-size: 4rem;
display: flex;
font-family: sans-serif;
font-variant: small-caps;
inline-size: 4rem;
text-align: center;
}

Elk vakje is een flexcontainer. De enige reden daarvoor is het makkelijke verticaal centreren. Het had wat dat betreft ook een gridcontainer kunnen zijn. Horizontaal is de tekst gecentreerd met text-align.

Voor de speciale vakjes hangt de opmaak af van het data-attribuut data-bonus. De tekst van data-bonus wordt ingelezen als content voor het pseudo-element ::before. Met de attribuutselector ‘waarde moet beginnen met’ worden de verschillende stijlen toegepast.

[data-bonus]::before {
content: attr(data-bonus);
}

[data-bonus^="3 x woord"] {
background-color: red;
box-shadow: 0 0 2px 1px red;
}

Opmaak van de lettervakjes

De CSS voor de lettervakjes is wat uitgebreider, maar bijzonderheden zijn er niet. De letter komt uit een data-attribuut, net als de puntwaarde van de letter. Het vakje is een flexcontainer voor de uitlijning van de letter. De letterwaarde is absoluut gepositioneerd. Je zou dat allemaal kunnen vervangen door een grid, maar daar wordt de code niet beter of duidelijker van.

<span class="letter" data-letter="F" data-waarde="4"></span>
.letter {
align-items: center;
background-color: hsl(33 100% 88%);
block-size: 4rem;
box-shadow: 1px 1px 4px hsl(33 100% 20%);
color: hsl(33 0% 5%);
cursor: pointer;
display: flex;
font-size: 2rem;
font-variant: small-caps;
inline-size: 4rem;
justify-content: center;
margin: 1px;
padding: .5rem;
position: relative;
text-transform: capitalize;
transform: translate(-1500px, -1500px);
}

.letter::before {
content: attr(data-letter);
}

.letter::after {
bottom: 0.5em;
content: attr(data-waarde);
font-size: 50%;
position: absolute;
right: 0.5em;
}

De regel transform: translate(-1500px, -1500px); plaatst alle letters buiten beeld, zodat ze bij het laden van de pagina met een animatie op hun plek gezet kunnen worden. Het is een eenvoudige translate-animatie, maar je kunt het natuurlijk zo gek maken als je wilt.

De trigger zit in de letterselectors, dat zijn allemaal genummerde childselectors. De animatieregel bevat achtereenvolgens de naam van de animatie, de duur en het effect. Daarna komt de startvertraging en die is bij elke volgende letter wat langer, zodat de letters na elkaar worden geplaatst. Als laatste zorgt forwards ervoor dat het laatste beeld van de animatie blijft staan. Zonder die aanwijzing zou de letter gelijk weer uit beeld verdwijnen, volgens de transformregel in de klasse .letter.

.letter:nth-child(10) {
grid-area: 6 / 6;
animation: drop-letter .5s ease-in-out 2.0s forwards;
}

@keyframes drop-letter {
0% {
transform: translate(-1500px, -1500px);
}
50% {
transform: translate(-250px, -250px);
}
100% {
transform: translate(0, 0);
}
}

Op dit punt heb je een scrabblebord waarop vanzelf letters worden geplaatst. Speelbaar is het nog niet. Daar ga ik een jaartje over nadenken!