Perché non userei Next JS e perché scommetterei su Remix
Ho cercato di mettere a confronto i due framework: Next e Remix. Non c'è un vincitore netto, ciascuno ha i suoi tradeoff.
X, formerly Twitter.
Un continuo celodurismo tra creatori di framework che sotto le spoglie di abili mercanti urlano e sbracciano vantandosi dei loro prodotti ed influencer che mettono il loro carico a suon di titoli raige bait: se oggi caldeggiano per uno, domani caldeggeranno per l’altro.
Questi ultimi parlano prima alla pancia, poi alla testa: la community si anima nei commenti culminando in frecciatine, sfociando in litigi (minacce persino) e uscite sceniche con annessi video strappalacrime.
Tutta la miseria umana espressa in 280 caratteri.
Ma purtroppo è questa la piazza, e tra tutti questi mercanti che strillano e sviluppatori che si azzannano, io mi limito ad osservare per capirci qualcosa.
Ci sono volte in cui osservare troppo tuttavia fa bruciare gli occhi, fa male alla testa e allo stomaco.
Perciò quando osservare non basta, allora devo provare per capire veramente.
Prima di cominciare
Per evitare che diventi un lunghissimo e noiosissimo articolo da leggere (che tanto abbandonerai) vorrei fare questa premessa: io mi sento confuso quanto lo sei tu.
Ho dato per scontato molte cose, come la conoscenza base del “vecchio” Next JS con il “nuovo” Next JS e un’infarinatura di Remix. Abbi pazienza, non era mia intenzione scrivere così tanto.
Ehi, a me Next JS piaceva
Nella mia precedente carriera lavorativa mi sono trovato molto bene con Next: l’api era semplice, ti permetteva di fare quello che ti serviva (principalmente SEO) e paragonandolo a tecnologie precedenti (Gatsby ad esempio) era acqua fresca. Talmente mi è piaciuto che ci ho fatto il sito su cui ti trovi adesso e non vedo ragioni per cui aggiornarlo al nuovo paradigma.
Poi uscì Remix. Dapprima fui subito investito da un assoluto scetticismo nei confronti de “l’ennesimo metaframework che fa la stessa cosa di Next” fino a quando decisi di provarlo.
Per anni ho utilizzato React Router ed un elemento distintivo di questa libreria è sempre stato la sua semplicità d’uso: dichiarativa, semplice. Fa il suo lavoro. Routing.
Bene, mi sorprese molto il fatto che questo framework aveva esattamente React Router come base e lo hanno arricchito con tutta una sfilza di api per la parte server side, rendendo l’esperienza di sviluppo familiare, semplice e senza fronzoli.
La loro strategia è anche abbastanza nota: se sei un utilizzatore di React Router troverai abbastanza semplice anche passare a Remix - c’è una migration path, a proposito.
Il Next JS che usavo era, per intenderci, quello con la page directory e la sua principale limitazione ad esempio, era la creazione semplificata di sotto-layout basati per rotta e fu principalmente per questo che sin da subito Remix mi colpì tantissimo: perché si poteva fare con estrema facilità.
Da lì a poco anche Next adottò la medesima soluzione (seppur con delle dovute differenze) a layout, introducendo la app directory.
Due cose erano chiare nella versione di Next con la page directory
Cosa avviene lato client
Cosa avviene lato server e staticamente generato
Qualcosa è cambiato
Oggi Next JS ha perso quella separazione netta tra cosa avviene lato server e cosa lato client, ma a dire il vero non è proprio tutta colpa (o merito) sua.
Il team di Next JS lavora a strettissimo contatto con il core team di React, ed è stato il primo ad implementare la feature più attesa degli ultimi anni: i server component. Però più nello specifico ci sono alcune cose che non mi piacciono molto.
La strategia di caching di Next JS ad esempio altera uno standard web: la fetch api. Con questo “monkey patching” se non si specifica l’opzione {cache: 'no-store'}
la chiamata verrà permanentemente cachata. Se ad esempio ho una libreria interna che si occupa di fare delle chiamate fetch e non è detto che io abbia la possibilità di modificare tali opzioni della chiamata… come faccio a dirgli di non cachare?
Rendendosi conto del pasticcio, Next JS sta cercando di rimediare con l’api cache
, con un approccio che ricorda molto quello di React Query.
Next JS utilizza funzionalità canary e non è che mi vada a genio utilizzare delle funzionalità che in futuro potrebbero cambiare - è già successo di recente con useFormState
con l'introduzione di useActionState
- mi spiace Jack, dovrai modificare il tuo corso.
Gli approcci suggeriti spesso sono ancora dei goffi tentativi di fare le cose nel verso giusto. È un esempio lampante il corso di Jack Harrington su come gestire i form per dirne uno. Oppure un banalissimo listing di una tabella ad esempio (ehi, facciamo un sacco di applicazioni backoffice). Bisogna adottare approcci e nuove astrazioni che non li definirei esattamente "intuitivi".
Per quanto riguarda le server action ed il modo in cui si invalida la cache lo trovo estremamente strano ed error prone.
Come dicevo prima, tutte le chiamate fetch
che avvengono lato server vengono cachate se non viene data particolare istruzione, tuttavia possono essere invalidate. Come?
Immaginiamo una chiamata simile:
fetch(url, { next: { tags: ["users"] } });
Quella particolare proprietà serve a Next per potergli assegnare una sorta di path a quella chiamata. Su questo approccio si può tracciare un parallelismo su come funziona react-query, semplificando:
Il server esegue la
fetch
nell’esempio sopra;Salva il risultato in un oggetto in memoria sotto la chiave
users
- ripeto, è una semplificazione, non è esattamente cosìAlla prossima chiamata alla fetch con questo stesso tag, Next prima verificherà che la chiave
users
esista, se esiste rileggerà quella risposta. Altrimenti rieseguirà la suddetta chiamata e la risalverà per un nuovo giro di giostra.
Per fare l’invalidazione si può procedere fondamentalmente in 2 modi:
Invalidando quella chiamata specifica sfruttando il tag (usando
revalidateTag
);O invalidando tutte le chiamate relative al path di routing usando
revalidatePath
(es:revalidatePath('/blog/[slug]', 'page')
).
È un po’ artificiale, un po’ magico ma occorre un minimo di organizzazione su questo punto per non incorrere a problemi.
Come dicevo prima, in Vercel si sono resi conto che questa storia del monkey patching si è rivelata problematica, così stanno cercando di tornare sui loro passi con quell’approccio in pieno stile React Query.
import { getUsers } from './data';import { unstable_cache } from 'next/cache';const getCachedUsers = unstable_cache(async (id) => getUsers(),['users'],{ tags: ["users"] });export default async function Component({ userID }) {const users = await getCachedUsers(userID);...}
Decisamente meglio, tuttavia questa api è ancora soggetta a modifiche e potrebbe variare in futuro - leggasi: usare con cautela.
Nested routing
Sul nested routing ammetto di essere combattuto: è estremamente frammentario ma anche potente. Ci saranno tante page.tsx
ma quello che più ho trovato incredibile è la funzionalità delle intercepting routes, si possono fare belle cosette.
Per avere un’idea delle potenzialità di questa funzionalità (senza ammorbarvi con l'implementazione che è consultabile), cercherò di proporre un esempio più visuale.
In quest’esempio abbiamo un layout molto familiare: un’header sopra, una sidebar laterale ed un contenuto al centro che varia frequentemente.
Una classica dashboard.
È facile a questo punto identificare delle parti di layout comune: header e sidebar.
Il designer però poi ti dice che su una determinata pagina quell’header dovrà avere un pulsante, in un’altra 2 pulsanti e in un’altra ancora qualcos’altro.
È un caso d’uso poco comune ma forse non così tanto. Qui entrano in gioco le parallel routes.
A questo punto, sfruttando questo meccanismo potremmo potenzialmente creare dei layout paralleli per tutte quelle rotte particolari e lasciare un comportamento di default su tutte le altre.
C’è una componente magica anche qui, perché basterà creare un file @header/default.js
e un altro file @header/page.js
, in questo modo del file layout.js
avrete una prop header
che potete usare come se fosse un children. Non mi voglio dilungare nell’implementazione però.
Questa funzionalità torna sicuramente molto comoda per casi come questi, anche se forse, può generare tanta confusione (specialmente all’inizio).
Tuttavia questa magia è ottenibile in maniera “non magica” anche con Remix, utilizzando l’hook useMatches
ad esempio.
Perché mi sono fissato con Remix
Remix sta seguendo una maturazione più graduale ma anche più cauta rispetto a Next JS che sembra lanciato in una folle corsa.
Come dicevo prima chi ha lavorato in passato con React Router non avrà alcuna difficoltà con Remix: le api sono identiche e naturalmente aggiunge diverse utility per la parte server.
A differenza di Next JS vi è una separazione marcata tra la logica lato server e quella lato client. Un po’ come Next JS demarcava la linea server/client con getStaticProps
e getServerSideProps
, Remix usa i loader
per valutare i dati lato server e passarli lato client.
Ritornando all’esempio della lista, ecco come avverrebbe in Remix:
La strategia di caching è qualcosa che puoi gestire out-of-the-box “alla maniera standard” senza particolari magie e mistificazioni.
Ad esempio restituendo come header la chiave “Cache-Control” sul loader per cachare il risultato direttamente.
Remix gestisce le invalidazioni in una maniera molto semplice: se sottometti un form (se scegli di usare le api che ti fornisce) come comportamento di default invalida tutti i path delle rotte. Se per certi versi questa cosa può far storcere il naso (ehi, non mi va mica tanto bene che tutti i segmenti di rotta rieseguano la logica nei loader) da un lato è possibile intervenire facilmente su questo comportamento ottimizzandolo con la costante shouldRevalidate
.
Per spiegare meglio userò un bel disegnino.
Supponiamo di avere 3 segmenti di rotta: (qui vedete /
, /invoices/
, /invoices/id/
).
C’è da considerare che ogni segmento di rotta avrà un suo loader.
Ora, usando la nostra incredibile immaginazione, immaginiamo che nel componente renderizzato dal segmento di rotta /invoices/id/
ci sia un form. Cosa avviene al submit di questo form?
Rivalida tutti i loader mostrati in pagina. In questo modo tutti i loader di tutti i segmenti di rotta mostreranno i dati aggiornati in funzione dell’avvenuta azione.
Ovviamente questo è un meccanismo di default e come dicevo prima, può essere modificato.
Remix non ha ancora introdotto le server action (e a questo punto, visto il modello di Remix, mi chiedo quale sia il senso di implementarle, visto che i form sono strettamente legati al routing).
E quindi
Ho cercato di riassumere le differenze “sostanziali” tra i framework in questa tabella.
Vite e Webpack
I framework frontend attuali seppur tutti diversi l’uno dall’altro (anche se mano a mano stanno diventando indistinguibili) hanno un elemento comune che in questi anni ha saputo valorizzare la developer experience più di ogni altra cosa: Vite.
Con l’adozione di Vite, Remix si porta a casa tante comodità care ai developer che non intendono armeggiare con Webpack. Proprio quest’ultimo invece è usato sotto il cofano da Next e permette di sovrascrivere le sue configurazioni all’occorrenza.
Testing
Mi sembra quasi automatico l’accostamento a Vitest per i test: Remix integra una libreria per testare anche i loader, in questo modo si riesce a coprire anche la parte di integration testing. Next invece, suggerisce direttamente un approccio e2e, proprio per sopperire a questa mancanza.
Middleware
I middleware invece sono un tasto dolente al momento per Remix: nella documentazione si lascia intendere che i middleware non erano stati introdotti per una scelta di design, cosa a cui personalmente credo poco, tant’è vero che adesso sono in roadmap. D’altra parte Next li integra già… ma con qualche problemino.
RSC
Next JS usa già i server components mentre Remix no.
Una cosa su cui ero molto impaziente di vedere in Remix, era l’approccio che avrebbero scelto per i server components. Dal momento che l’obiettivo principale del team di Remix è di creare un prodotto fortemente improntato sulla buona DX, i loro sforzi si sono concentrati su come non creare confusione tra server component e client component.
Fino ad ora hanno utilizzato questo approccio ben separato:
loader: logica server
Componente: logica di UI
È praticamente certo che è sui loader su cui utilizzeremo i server component, viceversa, saranno client.
// loaderfunction loader() {const { title, content } = await loadArticle();return {// RSCarticleHeader: <Header title={title} />,// RSCarticleContent: <AsyncRenderMarkdownToJSX makdown={content} />,};}export default function Component() {const { articleHeader, articleContent } = useLoaderData();return (<main>{articleHeader}<React.Suspense fallback={<LoadingArticleFallback />}>{articleContent}</React.Suspense></main>);}
Web server
Una cosa che trovo di estrema rilevanza è il concetto di Remix come “handler”.
Di fatto Remix si basa sullo standard web di fetch che lo abilita ad essere implementato su qualsiasi web server node o deno utilizzando gli appositi adapter. Ce ne sono di pronti come express (che è quello di default) o fastify per dirne un paio. L’implementazione è incredibilmente semplice ed è documentata altrettanto bene.
Inoltre ci sono diversi boilerplate da cui si può partire.
Ad esempio qui ho creato un’implementazione custom di Remix usando Express - niente di trascendentale:
Giusto per aggiungerci un tocco personale (anche un po’ old fashioned 💅) ho messo il logger morgan
.
Se volessi implementare una qualche strategia di cache potrei anche intervenire qui.
Per quanto riguarda Next JS invece non c’è molta configurazione in tal senso, il framework in sé è accoppiato ad un suo web server.
Developer tools?
Con l’assottigliarsi della differenza client/server si inizia a sentire la necessità di un tool per capire cosa avviene lato server, qualcosa che framework mvc di vecchia data come symfony, ad esempio hanno.
A tal proposito, la community di Remix ha sviluppato un developer tool dedicato anche se immagino che questo sia un trend che prima o poi investirà tutti i metaframework.
In conclusione
Vercel con Next JS al suo debutto ha semplificato di gran lunga il modo con cui sviluppiamo oggi applicazioni con React sia client side che server side. Ha vissuto un periodo di incontrastata superiorità rispetto a tutti gli altri ma da qualche anno iniziano ad esserci dubbi sulla direzione che sta prendendo.
Il sospetto che Vercel voglia accasarsi le menti più brillanti del core team per attrarre più aziende verso i loro servizi è ormai palese ed inizia ad essere come minimo sospetta la collaborazione che la vecchia guardia del core team continua ad avere con l’attuale core team: non si capisce chi abbia la leadership e chi favorirebbe tale collaborazione.
Da subito dopo la pubblicazione di Remix, Vercel sembra si sia lanciata in una folle corsa per continuare ad essere la più innovativa sulla piazza ma qualcuno lamenta la sua DX. Le api sono tante, la documentazione dispersiva. Le versioni canary di react escono con la versioni stable di Next JS e questo è un problema: se ieri avessimo implementato funzionalità canary, oggi è possibile che tali funzionalità siano state riviste, sostituite, eliminate - come useFormState
.
È anche un dato di fatto che Vercel ti fa sentire al sicuro se usi i loro servizi, ma la documentazione diventa particolarmente ostica quando ne devi utilizzare di altri - ed è per questo che personalmente non lo sceglierei così convintamente su piattaforme non Vercel.
D’altra parte c’è Remix che propone un’api semplice - che non significa priva di funzionalità, seppure alcune si stanno facendo attendere - . Si sta muovendo in maniera decisamente più cauta e sta cercando di preservare una buonissima DX.
Puoi deployarlo ovunque ed è estremamente semplice farlo.
Ha una roadmap pubblica ed il framework progredisce anche grazie ai feedback che riceve.
In generale entrambi i framework sono fortissimi ed è terribilmente difficile dire A o B: ognuno ha i suoi tradeoff e non c’è una prevaricazione netta.
Io almeno una cosa la so: voglio sviluppare applicazioni web fatte bene e con uno strumento comodo e che mi garantisca una certa continuità.
Ed è per questo che userei Remix per il mio prossimo progetto.