- Letztes Update von React: Juni (!) 2022 (!!)
- ...es gibt aber mittlerweile einen "Canary Channel", auf dem Updates veröffentlicht werden, die für Bibliotheksanbieter gedacht sind
- auf diese Weise können die schon neue React-Features integrieren, bevor diese dann in einer stabilen Version erscheinen
- Auf diesem Kanal ist recht viel los, insb. wegen den neuen Server-Features
- Ein Hook mit dem man eindeutige Ids generieren kann
- Zum Beispiel für
aria-labelledby
etc in Komponentenbibliotheken - Das kann nützlich sein, wenn man SSR macht, und beim rendern aus Server- und Clientseite dieselbe "eindeutige" Id generiert werden soll
- und deswegen z.B. eine zufällige uuid nicht funktioniert
-
export function Input({label}) { const labelId = useId(); return <div> <label id={labelId}>{label}</label> <input htmlFor={id} /> </div> }
- Voraussetzung für Suspense
- Kann Rendern abbrechen
- Braucht man Anwendungsfälle für...
- Haben wir schon bei Next.js gesehen und sehen wir auch später nochmal
- Habe bislang keine guten Anwendungsfälle daür gefunden
- Als Debounce/Throttling in der Beispiel-Anwendung nicht gut geeignet
use
-Hook undcache
API- Suspense mit TanStack Query
- loader und actions mit dem React Router
- Auf dem Server können wir mittlerweile asynchrone Komponenten schreiben (RSC)
- Das geht auf dem Client nicht
- Um das arbeiten mit Promises zu vereinfachen, gibt es künftig:
- Mit dem
use
-Hook kann man auf ein Promise warten. - Darum kann eine Suspene-Komponente mit einem Platzhalter liegen
-
async function loadBlogPost() { /* ... */ } function BlogPost() { const post = use(loadBlogPost()); return <>...</>; } function BlogPostPage() { return <Suspense fallback={<h1>Posts loading...</h1>}> <BlogPost /> </Suspense> }
- Achtung!!: Der Aufruf von
loadBlogPost
erzeugt bei jedem Rendern ein neues Promise!- Damit kann man in eine Endlosschleife kommen
- Dafür wahr wohl mal der
cache
gedacht, aber der ist jetzt nur noch für die Serverseite
- Der
use
-Hook darf im Gegensatz zu anderen Hooks überall in einer Komponente verwendet werden- auch in if-Blöcken, Schleifen etc.
- Man kann mit dem
use
-Hook auf einen Context zugreifen. Das funktioniert genau wieuseContext
, nur dass der Hook überall verwendet werden kann: -
function PostEditor() { const handleSave = async () => { await savePost(); use(RouterContext).push(); } }
- Grundlagen: "Klassische" TanStack Query API
- Queries mit Suspense
- Verwendung mit
useTransition
- Beispiel-Anwendung
- Architektur
- Klassische Single-Page-Anwendung: Frontend ist Frontend, Backend ist Backend (mit REST API)...
- Es gibt diverse Parameter, die einstellen, was wann passieren soll
- Auch das ist nicht immer einfach zu verstehen und zu behalten
- Tipp: In jedem Fall die Default-Options lesen und immer wieder parat halten!
- Insbesondere die
refetch
-Einstellungen können sehr verwirrend und überraschend sein refetchOnWindowFocus
mache ich z.B. mindestens während der Entwicklung aus
- Insbesondere die
- Zentrales Konfigurationsobject:
QueryClient
- React-unabhängig
- Wird beim Starten der Anwendung initialisiert
- Oft reichen Default-Einstellung
- Es können aber z.B. globale Refetch-Policies eingestellt werden
- Das Objekt wird per QueryClientProvider in die Anwendung gereicht
-
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false } } }); root.render( <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> );
- Queries werden mit dem useQuery-Hook ausgeführt
- Der Hook erwartet ein Konfigurationsobjekt
queryKey
: Array mit Query Keys (zur Interaktion mit dem Cache)queryFn
: Funktion zum Laden der Daten- Weitere Konfigurationen (optional)
-
import { useQuery } from "react-query"; import { loadBlogPosts } from "./blog-api"; function BlogListPage() { const result = useQuery({queryKey: ['posts'], queryFn: loadBlogPosts}); // ... }
useQuery
liefert ein Objekt zurück:isLoading
: Der Query lädt noch (und es sind keine Daten im Cache)isSuccess
: Daten sind geladenisError
: Es ist ein Fehler aufgetretendata
enthält die geladenen Datenerror
: Fehlerobjekt aus der Query-Funktion- Weitere siehe Doku
-
function BlogPage({blogPostId}) { const result = useQuery(/* ... */); if (result.isLoading) { return <h1>Loading!</h1> } if (result.isError) { // result.error ist hier gesetzt return "Error: " + result.error; } if (result.isSuccess) { // data ist jetzt hier gesetzt return <BlogPost post={data} /> } }
- Mit den Query Keys wird ein Ergebnis im Cache gespeichert
- Ein Query Key besteht aus einem Array von Werten
- Üblicherweise ist es ein Name (z.B. "posts") und dann ggf. weitere Parameter, zum Beispiel die Id eines Posts ("P1")
oder die Sortierreihenfolge
- Also alle Daten, die den Query exakt beschreiben
-
import { useQuery } from "react-query"; import { loadBlogPosts } from "./blog-api"; function BlogPage({blogPostId}) { // Für jeden Aufruf mit einer neuen blogPostId // wird das Ergebnis separat in den Cache gelegt const result = useQuery({ queryKey: ['blogPost', blogPostId], queryFn: () => loadPost(blogPostId) }); // ... }
- Wenn ein Query mit denselben Query Keys in mehr als einer Komponente ausgeführt wird...
- ...stellt TanStack Query sicher, dass der Query nur einmal ausgeführt wird
- ...wenn sich das Ergebnis ändert, werden alle Komponenten, die den Query verwenden, automatisch aus dem Cache aktualisiert
useQuery
erwartet eine Query-Function, die den eigentlichen Request ausführt- Die Signatur ist fast beliebig, die Funktion muss aber ein Promise zurückliefern:
- Wenn die Daten erfolgreich geladen wurden, muss das Promise mit den Daten "aufgelöst" werden
- Wenn es einen Fehler gab, muss die Funktion einen Fehler werfen
-
// async function gibt IMMER ein Promise zurück export async function loadBlogPost(postId) { const response = await fetch("http://localhost:7000/posts" + postId); if (!response.ok) { throw new Error("Could not load blog post: " + response.status); } return response.json(); }
- Queries werden oft in eigenen Hooks zusammengefasst. Dann braucht man Query-Key und -Funktion und weitere Einstellungen nicht jedesmal neu anzugeben
-
function useBlogPostQuery(blogPostId: string) { return useQuery({ queryKey: ['blogPost', blogPostId], queryFn: () => loadPost(blogPostId) }); } function BlogPostPage() { const result = useBlogPostQuery("P1"); // ... }
- Da Query Keys oft an mehr als einer Stelle gebraucht werden (z.B. invalidieren von Queries, dazu später mehr), legt man diese so ab, dass man Zugriff darauf hat. Man kann dafür auch eine "Factory-Funktion" bauen
-
export const blogPostPageQueryKey = (postId: string) => (['blogPost', blogPostId]); function useBlogPostQuery(blogPostId: string) { return useQuery({ queryKey: blogPostPageQueryKey(postId), queryFn: () => loadPost(blogPostId) }); }
- Die Query-Funktionen würde ich - sofern sie einigermaßen trivial sind - direkt in dem Custom-Hook implementieren (anders als in den Beispielen bislang sehen)
-
function useBlogPostQuery(blogPostId: string) { return useQuery({ queryKey: blogPostPageQueryKey(postId), queryFn: async () => { const r = fetch("..."); // ... } }); }
- Validierung der Server-Antworten (und evtl. auch der Requests) mit Zod.
- wir arbeiten jetzt in
spa/spa-workspace
- hierin bitte Packages installieren und Anwendung starten
pnpm install
,pnpm dev
- Die Anwendung läuft auf http://localhost:3200
- Auf der Blog-Übersichtsseite fehlen die Daten 😱
- Implementiere bitte die
BlogListPage
-Komponente. Der Rumpf der Komponente ist schon inBlogListPage.tsx
vorhanden. Es fehlt "nur" der Code zum Laden der Daten... - 👮 CoPilot ist verboten!
- Als Query-Funktion kannst Du
getBlogTeaserList
ausbackend-queries.ts
angeben - Du kannst
useQuery
oderuseSuspenseQuery
verwenden- Bei
useSuspenseQuery
an denSuspense
-Boundary danken!
- Bei
- Zeige eine Warte-Meldung an, während die Daten geladen werden
- Zum künstlichen Verzögern der Daten
getBlogTeaserListSlowdown
inbackend-queries.ts
setzen
- Zum künstlichen Verzögern der Daten
- Mutations werden verwendet, um Daten zu schreiben
- Mutations haben keinen Cache-Key und werden auch nicht automatisch ausgeführt
- Die Mutation-Funktion entspricht der Query-Function (nur dass sie Daten schreibt und nicht liest)
- Auch der
useMutation
-Hook liefert Informationen über den Zustand der Mutation zurück - Außerdem wird eine Funktion (
mutate
) zurückgeliefert, die die Mutation ausführt - Übergeben wird der Funktion die zu schreibenden Daten
-
import { useMutation } from "react-query"; import { savePost } from "./blog-api"; function PostEditorPage() { const mutation = useMutation({ mutationFn: savePost, onSuccess() { // optional: wird aufgerufen, wenn die Mutation erfolgreich war // ... } }); if (mutation.status === "error") { return <h1>Error!</h1>; } if (mutation.status === "loading") { return <h1>Saving, please wait!</h1>; } return <PostEditor onSavePost={mutation.mutate} />; }
- Die
mutationFn
kann genau einen Parameter annehmen (oder keinen) - Den Wert für diesen Parameter gibst Du beim Ausführen der Mutation an die
mutate
-Funktion- (diese ruft die
mutationFn
dann ihrerseits mit diesem Parameter auf)
- (diese ruft die
- Damit kannst du z.B. die zu speichernden Daten übergeben
- Wenn Du mehrere Informationen übergeben willst, musst Du ein Objekt übergeben
-
function PostEditor() { const mutation = useMutation({ mutationFn: ({ title, body }: NewPost) => { return addPost(title, body); } // ... }); const handleSave = () => { mutation.mutate({ title: "...", body: "..." }) } }
- Neben der
mutate
-Funktion gibt liefertuseMutation
einemutateAsync
-Funktion zurück - Diese liefert ein Promise zurück, das mit dem Ergebnis der Mutation aufgelöst wird
- Das kannst Du in einer Komponente z.B. nutzen, um nach der erfolgreichen Mutation eine weitere Aktion auszulösen
-
function PostEditor() { const mutation = useMutation({ /* ... */ }); const handleSave = async () => { const result = await mutation.mutateAsync(post, title); if (result.status === "success") { router.push("/blog"); } } }
- Alle Query-Ergebnisse von
useQuery
werden automatisch gecached - Alle Komponenten werden aktualisiert, wenn sich der Cache aktualisiert
- Per Default werden Queries automatisch neu ausgeführt:
- Komponente wird (neu) gemounted
- Browser-Fenster bekommt den Focus
- Nachdem das Netzwerk offline war
- Queries können per API manuell erneut ausgeführt werden
- Das kann zum Beispiel nach einer Mutation sinnvoll sein, um die geänderten/gespeicherten Daten im Cache zu aktualisieren
- Dazu wird die Funktion
invalidateQueries
oderrefetchQueries
vomQueryClient
verwendet - Übergeben werden die Query Keys, deren Queries erneut ausgeführt werden sollen
-
import { useMutation, useQueryClient } from "react-query"; import { savePost } from "./blog-api"; function PostEditorPage() { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: savePost, onSuccess() { // PostPage-Query als "veraltet" markieren, // beim nächsten Rendern einer Komponente, die darauf zugreift, // werden die Daten neu gelesen queryClient.invalidateQueries(['posts']); // Queries unmittelbar neu ausführen queryClient.refetchQueries(['posts']); } }); // ... }
- Ein einzelnes BlogPost kann im Cache verbleiben, da es sich in unserer App nicht ändert/nicht ändern kann
- Mit den
refetch
-Optionen kann die automatische Aktualisierung ausgeschaltet werden -
function PostPage() { // ... const result = useQuery({queryKey: ["blogPost", postId], queryFn: () => loadBlogPost(postId!)}, { refetchOnMount: false, refetchOnWindowFocus: false }); // ... }
- Das von
useQuery
zurückgeliefert Objekt enthält auch einerefetch
-Funktion um einen Query manuell neu auszuführen -
function PostListPage() { const result = useQuery({queryKey: ['posts'], queryFn: readPosts}, { // nicht automatisch aktualisieren refetchOnMount: false, refetchOnWindowFocus: false }) // ... result.status === loading, status === error ... return <div> <button onClick={refetch}>Reload Posts</button> <PostList posts={data} /> </div> }
- Vervollständige die
PostEditor
-Komponente - In der
handleSave
-Funktion soll eine Mutation ausgeführt werden, die du implementieren musst- Die Mutation kann als
queryFn
die FunktionaddPost
ausserver-actions.ts
verwenden, um das Post auf dem Server zu speichern - Wenn die Mutation fehlschlägt, sollte im PostEditor eine Fehlermeldung angezeigt werden
- Das kannst Du testen, in dem du einen
title
eingibst bzw. speicherst, der weniger als fünf Zeichen lang ist. - Wenn der Benutzer nach dem fehlerhaften Speichern Änderungen macht (Eingabe in eins der Eingabefelder) soll die Fehlermeldung wieder verschwinden
- Das kannst Du testen, in dem du einen
- Wenn die Mutation erfolgreich war, soll zur
/blog
-Übersichtsseite navigiert werden (dafür kannst DuuseNaviagate
vom React Router verwenden) - Kannst Du einen Custom Hook für die Mutation schreiben (
useSavePostMutation
) ?- Die Navigation soll nicht Bestandteil des Hooks sein
- Der neue, gespeicherte, Blog Post soll auf der
/blog
-Übersichtsseite natürlich dann auch sichtbar sein...
- Die Mutation kann als
- Seit Version 5 bietet TanStack Query Support für Suspense
- Damit können wir - wie in Next.js gesehen - per
Suspense
-Komponente festlegen, an welcher Stelle in der Komponentenhierarchie auf ausstehende Daten gewartet werden soll - Anstatt
useQuery
verwenden wiruseSuspenseQuery
- Der Hook funktioniert sehr ähnlich zu
useQuery
, aber:- das Ergebnis enthält immer Daten (
data
), denn wenn es noch keine Daten gibt, wird solange die Suspenseplaceholder
-Komponente angezeigt
- das Ergebnis enthält immer Daten (
-
function BlogListPage() { return <React.Suspense fallback={<h1>Posts loading...</h1>}> <BlogList /> </React.Suspense> } function BlogListPage() { const { data } = useSuspenseQuery({ queryFn: () => { /* ... */ }, queryKey: ["blog-list"] }); // data ist hier in jedem Fall definiert return <PostList posts={data} /> }
- Mit den default
refetch
-Einstellungen wird ein Query erneut ausgeführt, wenn eine Komponente gemounted wird, oder das Fenster den Focus bekommt - Wenn der Query zu dem Zeitpunkt bereits einmal ausgeführt wurde, und sich Daten dafür im Cache befinden, werden zunächst diese alten Daten angezeigt
- Der
placeholder
wird dann nicht angezeigt, da aus Supsene-Sicht auf keine Daten gewartet wird
- Der
useQuery
unduseSuspenseQuery
-Hook liefern das PropertyisFetching
zurück, mit dem Du erkennen kannst, ob im Hintergrund gerade deine Daten aktualisiert werden- Fehler werden über einen Error Boundary behandelt
- Damit kannst Du z.B. einen Loading Spinner oder einen anderen Hinweis anzeigen, wenn die Daten neu geladen werden
-
function BlogListPage() { const { data, isFetching } = useSuspenseQuery({ queryFn: () => { /* ... */ }, queryKey: ["blog-list"] }); // data ist auch hier in jedem Fall definiert return <div> {isFetching && "Updating data..."} <PostList posts={data} /> </div> }
- Stelle die PostListPage auf
useSuspenseQuery
um - Du musst außerdem ein
Suspense
-Boundary festlegen. - Wenn Du den Aufruf des Queries verzögerst (
getBlogTeaserListSlowdown
inbackend-queries.ts
) und zwischen Blog Post-Ansicht und Übersichtsseite wechselt, was passiert dann? - Stelle die beiden
useQuery
-Aufrufe inBlogPostPage
undCommentList
um, die ein einzelnes BlogPost bzw. dessen Kommentare darstellen- Für die beiden Queries ist in
BlogPostPageRoute
schon einSuspense
-Boundary definiert - Diese Komponente (
BlogPostPageRoute
) ist in der Router-Konfiguration für/blog/:postId]
gemappt
- Für die beiden Queries ist in
- Was passiert, wenn Du dann um den Aufruf der
CommentList
-Komponente (k)einSuspense
legst? - Zum besseren Testen kannst Du die Aufrufe des Backends in der Datei
backend-queries.ts
verzögern (s. Konstanten am Anfang der Datei):getBlogTeaserListSlowdown
: Blog ÜbersichtsseitegetBlogPostSlowdown
: lesen eines Einzelnen Blog PostsgetCommentsSlowdown
: Lesen der Kommentare zu einem Blogpost- Achtung: die Angaben sind etwas verschieden, aber du findest Beispiel in
backend-queries.ts
- In der
BlogPostPage
haben wir einen "Request-Wasserfall":- die Artikel werden gelesen und
- erst danach wird die
CommentList
gerendert und darin werden die Kommentare gelesen
- Um die Darstellung zu beschleunigen, wäre es hilfreich, wenn beide Queries zeitgleich starten würden
- Zum "Vorladen" von Daten können wir die Funktion
QueryClient.ensureQueryData
verwenden - Diese erwartet dieselben Angaben wie
use(Suspense)Query
, nämlich u.a.queryKey
undqueryFn
- Wenn sie ausgeführt wird, und für den angegeben
queryKey
noch keine Daten im Cache sind, lädt die Funktion die Daten im Hintergrund - Damit können wir in der
BlogListPage
schon das Laden der Daten triggern. Wenn dann dieCommentList
gerendert wird, sind die Daten evtl. schon im Cache, aber zumindest läuft der Request schon -
function BlogPostPage({ postId }: BlogPostPageProps) { // start early fetching of comments... const queryClient = useQueryClient(); queryClient.ensureQueryData({ queryKey: ["blogpost", postId, "comments"], queryFn: () => getComments(postId), }); const { data: post } = useSuspenseQuery({ queryKey: ["blogpost", postId], queryFn: () => getBlogPost(postId), }); return ( <div className={"space-y-4"}> <Post post={post} /> <Suspense fallback={<LoadingIndicator>Comments loading...</LoadingIndicator>} > <CommentList postId={postId} /> </Suspense> </div> ); }
- Bei der Arbeit mit Mutations ändert sich gegenüber der "konventionellen" Variante zunächst nichts, wir nutzen weiterhin den
useMutation
-Hook. - Allerdings können wir sicherstellen, dass nach der Mutation solange gewartet wird, bis der Cache aktualisiert wurde
- Damit können wir erreichen, dass wir auf einer Seite (in dem Fall z.B.
PostEditor
) bleiben, bis die Ziel-Seite (Blog Übersichtsseite) vollständig neu gerendert wurde - Aus Benutzer-Sicht sieht es dann so aus als ob es ein Vorgang wäre und nicht zwei (1. Speichern und 2. Neuladen der Posts)
- Dazu musst Du in der
onSuccess
-Methode einer Mutation so lange warten, bis auch das Aktualisieren des Caches abgeschlossen ist- Aus Sicht des Aufrufers der Mutatio mit
mutateAsync
sieht es dann auch wie eine Operation aus
- Aus Sicht des Aufrufers der Mutatio mit
- wenn Du den Cache der Ziel-Seite (bzw. dessen Query) mit
invalidateQueries
nur invalidierst oder aus dem Cache entfernst, werden die Daten nicht neu geladen, sondern erst, wenn der Query wieder "benötigt" wird- Das ist dann erst auf der Ziel-Seite der Fall, so dass hier die Ziel.Seite sofort aufgerufen und der
placeholder
angezeigt wird (oderisRefetching: true
ist) - wenn Du stattdessen in
onSuccess
await refetchQueries()
verwendest, wird der Query sofort ausgeführt undonSuccess
kehrt erst zurück, wenn die Daten geladen und der Cache aktualisiert wurde
- Das ist dann erst auf der Ziel-Seite der Fall, so dass hier die Ziel.Seite sofort aufgerufen und der
-
export default function PostEditor() { const queryClient = useQueryClient(); const addPostMutation = useMutation({ mutationFn: ({ title, body }) => { /* ... */ }, onSuccess: async (data) => { // await ist wichtig! await queryClient.refetchQueries({ queryKey: ["blog-list"], }); }, }); // mutateAsync wie gesehen }
- Identisches Setup wie TanStack Query: Frontend ist Frontend, Backend ist Backend
- Seit Version 6 vom React Router gibt es in React Router das Konzept eines DataRouters
- Diese kombinieren das Routing mit dem Data-Fetching (und Data-Mutating)
- Das ganze ist aus Remix hervorgegangen bzw. geht auch wieder dorthin zurück
- (Für Remix 3 ist ein Migrationspfad von SPA mit React Router nach Fullstack Remix geplant)
- Mitterweile ist der herkömmliche BrowserRouter auch ein DataRouter, d.h. unterstüzt das Laden von Daten
- Traditionell werden im React Router URL (Segmente) auf Komponenten abgebildet.
-
const router = createBrowserRouter([ { path: "/", element: <RootLayout />, children: [ { index: true, element: <LandingPage /> }, { path: "blog", children: [ { path: "add", element: <AddRouteLayout />, children: [{ index: true, element: <PostEditor /> }], }, { element: <BlogContentLayout />, children: [ { path: "post/:postId", element: <BlogPostPageRoute /> }, ], }, ], }, ], }, ]);
- Zusätzlich zu den "normalen" Routing-Angaben kann man pro Route eine
loader
-Funktion angeben - Diese ist dafür zuständig, die Daten für eine Route zu laden
- Erst wenn die Funktion die Daten geladen (und an den React Router zurückgegeben) hat, wird die Route dann gerendert
- Die geladenen Daten kann man in den Komponenten mit
useLoaderData
abfragen -
const router = createBrowserRouter( /* ... */ { path: "post/:postId", element: <BlogPostPageRoute />, loader: blogPageLoader, } ); async function blogPageLoader({params}) { const post = await fetch(`http://localhost:7020/post/${params.postId}`); // ... return post ; } function BlogPostPage() { const post = useLoaderData(); // render Post }
- Für die
loader
-Funktionen gibt es den TypeScript-TypenLoaderFunction
. Dieser beschreibt, wie Signatur und Rückgabe-Wert aussehen müssen. - Als Parameter werden euch an die loader-Funktion übergeben:
params
: die URL-Parameter bei variablen Segmenten (postId
)request
: Eine Instanz desfetch
request
-Objektes. Über diesen bekommt ihr u.a. Zugriff auf die aktuelle URL, um z.B. die SearchParameter auszulesen- Bin nicht ganz sicher, was ich davon halten soll
-
export const blogPageLoader: LoaderFunction = ({ params, request }) => { const postId = params.postId const url = new URL(request.url); const includeComments = url.searchParams.get("include_comments"); return fetch( /* ... */) }
- Innerhalb der
loader
-Funktion kannst Du denQueryClient
von TanStack Query verwenden, um darüber deine Daten zu laden - Dann kann deine Anwendung auch von den Cache-Features von TanStack Query profitieren
- Da eine
loader
-Funktion kein React Hook ist, kannst Du nicht mituseQuery
oderuseQueryClient
arbeiten - Stattdessen stellst Du die
QueryClient
-Instanz über einen Modul export zur Verfügung: -
const queryClient = new QueryClient(/* ... */); export {queryClient};
- In deinen
loader
-Funktionen kannst Du auf denQueryClient
zugreifen und mitfetchQuery
die Daten laden fetchQuery
funktioniert im Grunde wieuseQuery
nur "ohne Komponente"-
import {queryClient} from "./query-client"; export function blogListPageLoader() { return queryClient.fetchQuery({ queryKey: ["blog-list"], queryFn: () => fetch( /* ... */ ), }) }
- Wir arbeiten im Verzeichnis
datarouter/datarouter-workspace
- Dieses Verzeichnis in Editor/IDE öffnen
- Darin Packages installieren und Vite starten:
pnpm install
pnpm dev
- Die Anwendung läuft auf http://localhost:3300
- In der
PostListPage
fehlt die loader-Funktion! - Bitte implementiere die
loader
-Funktion (kannst Du direkt inBlogListPage.tsx
machen)- Verwende den
QueryClient
von TanStack Query - Zum Laden der Daten mit der
queryFn
-Funktion kannst DugetBlogTeaserList
ausbacken-queries.ts
verwenden
- Verwende den
- Gib die
loader
-Funktion in der Routen-Beschreibung inmain.tsx
an - Zeige die mit der
loader
-Funktion gelesenen Blog-Psts in derBlogListPage
-Komponente an - Hinweis: in
shared/api/types.ts
findest Du TypeScript-Typen für die Objekte, die über die API gelesen werden (z.B.BlogPostTeaser
,BlogPost
undComment
) - Lösung:
steps/10-loader-function
- Das Rendern einer Route wird verzögert, bis alle Daten geladen wurden (bzw. das Promise der loader-Funktion aufgelöst wurde)
- Wir können aber auch mit dem React Router die
Suspense
-Komponente von React verwenden, um Inhalte zu priorisieren - Dazu können Daten innerhalb der
loader
-Funktion als "defered" (verzögert, bzw. "kommen später") gekennzeichnet werden. - Die
loader
-Funktion gibt dann ein Objekt zurück, das mitdefer
erstellt wird. Darin enhalten können sein:- geladene Daten (auf die gewartet werden soll)
- (ausstehende) Promises für Daten (auf die nicht gewartet werden soll)
-
export const blogPageLoader: LoaderFunction = async ({ params }) => { const { postId } = params; return defer({ // auf BlogPost wird beim Rendern gewartet blogPost: await queryClient.fetchQuery( /* ... */ ), // Auf die Kommentare wird NICHT gewartet, es wird ein Promise zurückgeliefert commentsPromise: queryClient.fetchQuery( /* ... */) }); };
- Mit
defer
wird ein oder mehrere Promises in die Komponente gegeben - Diese kann auf das aufgelöste Promise mit der
Await
-Komponente warten - Die
Await
-Komponente erwartet alsresolve
-Property ein Promise und als Kind-Elemente eine Funktion (wie "früher" vor den Hooks...🙀) - Diese Funktion ruft
Await
mit dem aufgelösten Promise-Wert auf - (Außerdem kann die Kompoente mit
errorElement
noch eine Komponente angeben, die gerendert wird, falls das Promise fehlschlägt) -
- Damit React in der Zwischenzeit einen Platzhalter anzeigen kann, muss darum eine
Suspense
-Komponente gelegt werden
- Damit React in der Zwischenzeit einen Platzhalter anzeigen kann, muss darum eine
-
function BlogPostPage() { const { blogPost, commentsPromise } = useLoaderData(); return <> <Post post={blogPost} /> <Suspense fallback={"Comments loading..."}> <Await resolve={commentsPromise}> { comments => <CommentList comments={comments} /> } </Await> </Suspense> </> }
- Als Alternative zur Render-Funktion (
children
) derAwait
-Komponente kann deruseAsyncValue
-Hook genutzt werden - Dieser kann unterhalb einer
Await
-Komponente eingesetzt werden, um den Wert des Promises zu bekommen, auf den (das in der Hierarchie am nächsten darüberliegende)Await
gewartet hat. -
function BlogPostPage() { const { blogPost, commentsPromise } = useLoaderData(); return <> <Post post={blogPost} /> <Suspense fallback={"Comments loading..."}> <Await resolve={commentsPromise}> <CommentList /> </Await> </Suspense> </> } function CommentList() { const comments = useAsyncValue(); return <>...</> }
- Die Daten für die
BlogPostPage
werden schon gelesen, aber nicht verzögert - Stell die Loader-Funktion
blogPageLoader
(BlogPostPage.tsx
) aufdefer
um, so dass Blog-Post und Kommentare als Promises zurückgegeben werden. - Baue die
BlogPostPage
so um, dass sie mitAwait
auf die beiden Promises wartet- Das
Await
für die Blog-Post-Daten soll mit einerchildren
-Funktion arbeiten - Baue die
CommentList
-Komponente so um, dass sieuseAsyncValue
verwendet - Welcher Ansatz gefällt dir besser?
children
oderuseAsyncValue
?
- Das
- Kannst Du die
BlogPostPage
mitSuspense
so bauen, dass der eigentlich Blog-Post schon angezeigt wird, auch wenn auf die Kommentare noch gewartet wird?- Dazu die
slowDown
-Konstanten inbackend-queries.ts
setzen (SlowDown für Kommentare muss länger sein als SlowDown für Artikel) - Beispiel:
const getBlogPostSlowdown = "?slowDown=1200"; const getCommentsSlowdown = "?slowDown=1600";
- Dazu die
- Lösung:
steps/20_defer
- Eine Route kann eine
action
definieren - Diese Funktion wird vom React Router aufgerufen, wenn die Route mit nicht-lesendem Zugriff (POST, PATCH, DELETE, PUT, ...) aufgerufen wird
loader
für GET,action
für (fast) alles andere
- Auch die
action
-Funktion bekommtparams
undrequest
übergeben - Das
Request
-Objekt kann in diesem Fall zum Beispiel den den Inhalt eines Formulars enthalten - Mit der React Router
Form
-Komponente kann ein Formular "submitted" werden, und die entsprechendeaction
-Funktion wird dann aufgerufen-
const router = createBrowserRouter({ path: "add", element: <PostEditor />, action: addPostAction }); const addPostAction: ActionFunction = async ({ params, request }) => { const { title, body } = await request.json(); const result = await fetch("/...", { method: "POST", body: JSON.stringify({title, body})); if (result.status === "success") { return redirect("/blog"); } return result; }; function PostEditor() { // Form-Komponente vom React Router (identische API mit HTML form-Element) return <Form method="POST"> <input name="title" /> <input name="body" /> </Form> }
-
- Was ist eure Meinung dazu? 🤔
--
- Stimmt das: a good e2e test lasts forever, no matter how often you change the implementation (https://x.com/acdlite/status/1731354993230319974?s=20)?
--