Replicache + Reflect: stack TypeScript local-first
Guida pratica all'implementazione di un'architettura local-first con Replicache per la sincronizzazione e Reflect per la gestione dello stato reattivo. Scopri come costruire applicazioni offline-first con binding dati automatici e conflict resolution.
Cos'è questo stack e perché dovrebbe interessarti
Replicache è una libreria TypeScript che sincronizza il database locale del client con il server, mantenendo lo stato in tempo reale e risolvendo automaticamente i conflitti. Reflect è un framework di data binding reattivo che trasforma gli oggetti JavaScript in osservabili automaticamente aggiornati. Insieme, formano uno stack completo per costruire applicazioni local-first dove il client è il source of truth primario e il server un replica trusted.
La combinazione è particolarmente potente perché Replicache gestisce la sincronizzazione probabilistica e la conflict resolution, mentre Reflect fornisce il binding dati reattivo senza boilerplate. Il risultato: applicazioni che funzionano perfettamente offline, sincronizzano automaticamente quando la rete è disponibile, e offrono un'esperienza utente immediata e fluida.
Architettura generale del sistema
Un'applicazione Replicache + Reflect è organizzata in tre strati:
I tre strati dell'architettura
- Client: Replicache sincronizza il database IndexedDB locale con il server; Reflect rende reattivi gli oggetti del modello di dati
- Mutation Engine: Le mutazioni del client vengono applicate localmente in modo ottimistico, poi replicate al server
- Server: Valida le mutazioni, risolve conflitti, e spinge le modifiche a tutti i client
Il flusso è: l'utente modifica un dato in UI → Reflect notifica il binding → Replicache applica la mutazione localmente (optimistic update) → Replicache sincronizza con il server via pull/push → il server valida e persiste → server spinge l'ack e altri client ricevono l'aggiornamento.
Setup iniziale del progetto
Inizia installando le dipendenze necessarie:
npm install replicache reflect-metadata rxjs
npm install -D typescript @types/node ts-node
Configura TypeScript con support per decoratori:
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true
}
}
Definire il modello di dati con Reflect
Usa Reflect per creare classi reattive:
import 'reflect-metadata';
import { Observable } from 'rxjs';
export class Todo {
id: string;
title: string;
completed: boolean;
createdAt: Date;
constructor(data: any) {
this.id = data.id;
this.title = data.title;
this.completed = data.completed;
this.createdAt = new Date(data.createdAt);
}
markComplete() {
this.completed = true;
}
updateTitle(newTitle: string) {
this.title = newTitle;
}
}
export class TodoStore {
private todos = new Map<string, Todo>();
addTodo(todo: Todo): void {
this.todos.set(todo.id, todo);
}
getTodo(id: string): Todo | undefined {
return this.todos.get(id);
}
getAllTodos(): Todo[] {
return Array.from(this.todos.values());
}
}
Inizializzare Replicache nel client
Crea un'istanza Replicache e definisci i pull e push handler:
import { Replicache } from 'replicache';
// Inizializza Replicache
const rep = new Replicache({
name: 'app-todo-list',
licenseKey: process.env.REPLICACHE_LICENSE || 'noop',
pullURL: 'http://localhost:3000/api/pull',
pushURL: 'http://localhost:3000/api/push',
});
// Definisci le mutazioni
export const mutators = {
async createTodo(
tx: any,
{ id, title }: { id: string; title: string }
) {
await tx.set(`todo/${id}`, {
id,
title,
completed: false,
createdAt: new Date().toISOString(),
});
},
async updateTodo(
tx: any,
{ id, completed, title }: any
) {
const todo = await tx.get(`todo/${id}`);
if (todo) {
await tx.set(`todo/${id}`, {
...todo,
...(completed !== undefined && { completed }),
...(title !== undefined && { title }),
});
}
},
async deleteTodo(tx: any, { id }: { id: string }) {
await tx.del(`todo/${id}`);
},
};
rep.mutators = mutators;
Implementare il pull handler sul client
Il pull handler scarica i dati dal server e aggiorna lo store locale:
// Nel client setup
rep.onPull.subscribe(async (pullRequest) => {
const response = await fetch('http://localhost:3000/api/pull', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientID: rep.clientID,
cookie: pullRequest.cookie,
}),
});
const data = await response.json();
// Replicache applica automaticamente i cambiamenti
// al database locale sincronizzando con lo store
});
Implementare il push handler sul server
Nel tuo backend Express/Node, gestisci i push dal client:
import express from 'express';
const app = express();
app.use(express.json());
// Archivio volatile per questo esempio (usa DB in produzione)
const todos = new Map<string, any>();
let serverVersion = 0;
app.post('/api/push', async (req, res) => {
const { clientID, mutations, lastMutationID } = req.body;
try {
for (const mutation of mutations) {
const { type, args } = mutation;
switch (type) {
case 'createTodo':
todos.set(args.id, {
id: args.id,
title: args.title,
completed: false,
createdAt: new Date(),
});
serverVersion++;
break;
case 'updateTodo':
if (todos.has(args.id)) {
const todo = todos.get(args.id);
if (args.completed !== undefined)
todo.completed = args.completed;
if (args.title !== undefined) todo.title = args.title;
serverVersion++;
}
break;
case 'deleteTodo':
todos.delete(args.id);
serverVersion++;
break;
}
}
res.json({ lastMutationIDReceived: lastMutationID });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Endpoint pull per sincronizzazione
Il server deve fornire un endpoint che Replicache interroga per sincronizzare lo stato:
app.post('/api/pull', (req, res) => {
const { cookie } = req.body;
const lastSeenVersion = cookie?.version ?? 0;
// Calcola le modifiche da inviare al client
const changes: any[] = [];
// In un'app reale, salveresti i cambiamenti con timestamp
// Per semplicità, restituiamo tutti i todos
const entries = Array.from(todos.entries()).map(([id, data]) => ({
key: `todo/${id}`,
value: data,
}));
res.json({
cookie: { version: serverVersion },
changes: entries,
});
});
Binding reattivo con Reflect
Usa Reflect per automatizzare il data binding tra il modello e la UI:
// Componente Vue 3 / React come esempio
import { ref, watch } from 'vue';
export default {
setup() {
const todos = ref<Todo[]>([]);
// Osserva i cambiamenti in Replicache
rep.subscribe(
(tx) => tx.scan({ prefix: 'todo/' }),
{
onData: async (data) => {
const todoList = [];
for await (const entry of data) {
todoList.push(new Todo(entry.value));
}
todos.value = todoList;
},
}
);
const createTodo = async (title: string) => {
const id = crypto.randomUUID();
await rep.mutate.createTodo({ id, title });
};
const toggleTodo = async (id: string) => {
const todo = todos.value.find((t) => t.id === id);
if (todo) {
await rep.mutate.updateTodo({
id,
completed: !todo.completed,
});
}
};
return { todos, createTodo, toggleTodo };
},
};
Conflict resolution e strategie
Replicache gestisce i conflitti usando Last-Write-Wins (LWW) di default. Puoi personalizzare la strategia:
// Strategia LWW personalizzata
rep.opts.conflictStrategy = 'lastWriteWins';
// O definire una strategia custom
export const conflictResolver = (
local: any,
remote: any,
base: any
): any => {
// Esempio: se il titolo è stato modificato su entrambi i lati,
// mantieni la versione più lunga (migliore UX)
if (local.title !== base.title && remote.title !== base.title) {
return {
...remote,
title:
local.title.length > remote.title.length
? local.title
: remote.title,
};
}
// Fallback: versione remota
return remote;
};
Ottimizzare performance e caching
Usa scan limitato e indexing per migliorare le query:
// Query efficienti con prefix scanning
const getCompletedTodos = async () => {
const results = [];
const scan = await rep.query((tx) =>
tx.scan({ prefix: 'todo/', limit: 100 })
);
for await (const { value } of scan) {
if (value.completed) {
results.push(value);
}
}
return results;
};
// Caching con TTL
const cacheKey = `completed-todos-${Date.now()}`;
const getCachedCompletedTodos = async () => {
const cached = await rep.query((tx) => tx.get(cacheKey));
if (cached) return cached;
const todos = await getCompletedTodos();
await rep.mutate.cache({ key: cacheKey, data: todos });
return todos;
};
Garantire esperienza offline-first
Con Replicache + Reflect, l'app rimane completamente funzionale offline:
Checklist offline-first
| Scenario | Comportamento |
|---|---|
| Utente offline creando todo | Applicato localmente, accodato per sync |
| Rete torna disponibile | Mutazioni auto-inviate al server |
| Conflitto risolto da server | Client riceve ack e sync state finale |
| Crash durante offline | IndexedDB persiste, ripresa al riavvio |
Best practices per la produzione
Ecco le raccomandazioni chiave per un'implementazione robusta:
Linee guida consigliate
- Versionamento: usa timestamp o versioni incrementali sul server per tracking preciso
- Autenticazione: valida sempre le mutazioni server-side con JWT o session token
- Persistenza: usa PostgreSQL o Firestore con ACID transactions per coerenza
- Throttling: limita la frequenza di push/pull (es. 1 push ogni 500ms)
- Logging: traccia conflitti e fallimenti di sync per debugging
- Testing: testa offline, con latenza artificiale e disconnessioni simulate
Domande frequenti
1. Qual è la differenza tra Replicache e altre librerie di sincronizzazione?
Replicache è progettato specificamente per local-first: il client è il source of truth primario, la sincronizzazione è probabilistica e i conflitti sono risolti automaticamente. Altre librerie (Firebase Realtime DB, Supabase) centrano il server come source of truth. Replicache garantisce offline-first con UX immediato.
2. Come Reflect migliora l'esperienza rispetto a observables manuale?
Reflect elimina il boilerplate di observer pattern. Invece di creare manualmente Observable<State>, Reflect rende reattivi gli oggetti nativi JavaScript con decoratori. Il binding è automatico e il codice è più leggibile.
3. Cosa succede se due client modificano lo stesso dato simultaneamente?
Replicache applica la mutazione localmente (optimistic), la accoda, poi sincronizza con il server. Il server valida entrambe le mutazioni, le applica in ordine temporale, e risolve il conflitto. Il client riceve l'aggiornamento risolto e sincronizza il suo stato locale.
4. Replicache è adatto per app di grandi dimensioni?
Sì, ma richiede infrastruttura scalabile. Replicache gestisce localmente fino a ~100k oggetti con IndexedDB. Per app più grandi, distribuisci più istanze del backend e usa database robusto. Monitoring è essenziale.
5. Quanto aumenta la complessità di deployment?
Poco. Aggiungiti soltanto due endpoint (/api/push e /api/pull) al tuo backend. Il resto della applicazione rimane invariato. La complessità maggiore è nel testing offline e nella gestione di sincronizzazione robusta.

Commenti
Caricamento commenti...
Accedi per commentare