Construire des applications collaboratives Local-First avec Yjs et React

La façon dont nous construisons les applications web est en train de changer. Pendant des années, le modèle cloud-first a dominé : chaque frappe de clavier voyage vers un serveur, est traitée, puis revient au client. Mais que se passe-t-il quand le réseau tombe ? Et si le serveur est lent ? Et si deux personnes éditent le même document simultanément ?
Le logiciel local-first résout ces problèmes en gardant les données sur le dispositif de l'utilisateur et en les synchronisant en arrière-plan. Le résultat : des interactions instantanées, un support hors ligne complet et une collaboration en temps réel sans conflits.
Ce que vous allez construire : Un éditeur de texte collaboratif où plusieurs utilisateurs peuvent éditer le même document simultanément — même hors ligne. Les modifications se synchronisent automatiquement lorsque la connectivité est rétablie, sans aucun conflit.
Ce que vous apprendrez
À la fin de ce tutoriel, vous comprendrez :
- Ce que sont les CRDTs et pourquoi ils permettent la collaboration sans conflits
- Comment intégrer Yjs (une bibliothèque CRDT de production) avec React
- Comment construire un éditeur de documents partagé avec synchronisation en temps réel
- Comment ajouter le support hors ligne avec stockage local persistant
- Comment configurer un serveur WebSocket de signalisation pour la synchronisation pair-à-pair
- Les stratégies pour mettre à l'échelle les applications local-first en production
Comprendre les CRDTs et l'architecture Local-First
Le problème de la synchronisation traditionnelle
Dans une application cloud-first typique, deux utilisateurs éditant le même champ crée un conflit. L'utilisateur A change un titre en "Bonjour" tandis que l'utilisateur B le change en "Monde" — une modification gagne, l'autre est perdue. C'est le problème du dernier écrit gagne.
Les solutions traditionnelles incluent :
- Le verrouillage — une seule personne peut éditer à la fois (frustrant)
- La Transformation Opérationnelle (OT) — des algorithmes complexes nécessitant un serveur central (utilisé par Google Docs)
- La résolution manuelle des conflits — montrer un diff aux utilisateurs et leur demander de fusionner (lent)
CRDTs : une meilleure approche
Les Types de Données Répliquées sans Conflit (CRDTs) sont des structures de données conçues pour que les éditions concurrentes convergent toujours vers le même état, quel que soit l'ordre d'application. Pas de conflits, pas de serveur central requis.
Il existe deux types principaux :
| Type | Fonctionnement | Exemple |
|---|---|---|
| Basé sur l'état (CvRDT) | Fusionne des instantanés d'état complets | G-Counter, LWW-Register |
| Basé sur les opérations (CmRDT) | Envoie et applique des opérations individuelles | Yjs, Automerge |
Yjs utilise des CRDTs basés sur les opérations, optimisés pour l'édition de texte, ce qui le rend parfait pour les éditeurs collaboratifs, les tableaux blancs et les structures de données partagées.
Pourquoi Yjs ?
Yjs se distingue parmi les bibliothèques CRDT pour plusieurs raisons :
- Éprouvé en production — utilisé par Notion, JupyterLab et des centaines d'applications
- Empreinte minimale — environ 16 Ko gzippé pour la bibliothèque de base
- Types de données riches — Y.Text, Y.Array, Y.Map et Y.XmlFragment
- Écosystème de fournisseurs — WebSocket, WebRTC, IndexedDB et plus
- Agnostique au framework — fonctionne avec React, Vue, Svelte ou JavaScript vanilla
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé
- npm ou pnpm comme gestionnaire de paquets
- Des connaissances de base en React et TypeScript
- Un éditeur de code (VS Code recommandé)
- Deux fenêtres de navigateur pour tester la collaboration
Étape 1 : Configuration du projet
Créez un nouveau projet React avec Vite et installez les dépendances nécessaires :
npm create vite@latest collab-editor -- --template react-ts
cd collab-editorInstallez Yjs et les paquets de son écosystème :
npm install yjs y-websocket y-indexeddb @tiptap/react @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursorVoici ce que fait chaque paquet :
| Paquet | Rôle |
|---|---|
yjs | Bibliothèque CRDT de base |
y-websocket | Fournisseur WebSocket pour la synchronisation en temps réel |
y-indexeddb | Fournisseur IndexedDB pour la persistance hors ligne |
@tiptap/react | Framework d'éditeur de texte riche pour React |
@tiptap/starter-kit | Extensions Tiptap essentielles (gras, italique, titres) |
@tiptap/extension-collaboration | Intégration Yjs pour Tiptap |
@tiptap/extension-collaboration-cursor | Afficher les curseurs des autres utilisateurs |
Étape 2 : Comprendre le modèle de données Yjs
Avant d'écrire du code, comprenons comment Yjs structure les données. Un document Yjs (Y.Doc) est un conteneur pour les types de données partagés :
import * as Y from 'yjs'
// Créer un nouveau document Yjs
const ydoc = new Y.Doc()
// Types de données partagés - accessibles par nom
const ytext = ydoc.getText('editor') // Texte collaboratif
const yarray = ydoc.getArray('items') // Liste collaborative
const ymap = ydoc.getMap('metadata') // Clé-valeur collaborative
// Les modifications sont automatiquement suivies
ytext.insert(0, 'Bonjour le monde !')
ymap.set('title', 'Mon Document')
yarray.push(['élément1', 'élément2'])Concepts clés :
- Chaque type partagé est accessible par un nom de chaîne sur le document
- Toutes les modifications sont enregistrées comme des opérations dans un journal en ajout seul
- Les opérations peuvent être appliquées dans n'importe quel ordre et convergent toujours
- Le document peut être encodé dans un format binaire compact pour le transfert
Étape 3 : Configuration du fournisseur de documents Yjs
Créez un hook personnalisé qui initialise le document Yjs avec WebSocket (pour la synchronisation en temps réel) et IndexedDB (pour la persistance hors ligne) :
// src/hooks/useYjsDocument.ts
import { useEffect, useMemo } from 'react'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
interface UseYjsDocumentOptions {
roomName: string
userName: string
userColor: string
serverUrl?: string
}
export function useYjsDocument({
roomName,
userName,
userColor,
serverUrl = 'ws://localhost:1234',
}: UseYjsDocumentOptions) {
const ydoc = useMemo(() => new Y.Doc(), [])
useEffect(() => {
// Fournisseur 1 : WebSocket pour la synchronisation avec les pairs
const wsProvider = new WebsocketProvider(
serverUrl,
roomName,
ydoc
)
// Définir la conscience utilisateur (position du curseur, nom, couleur)
wsProvider.awareness.setLocalStateField('user', {
name: userName,
color: userColor,
})
// Fournisseur 2 : IndexedDB pour la persistance hors ligne
const idbProvider = new IndexeddbPersistence(roomName, ydoc)
idbProvider.whenSynced.then(() => {
console.log('Données locales chargées depuis IndexedDB')
})
return () => {
wsProvider.destroy()
idbProvider.destroy()
ydoc.destroy()
}
}, [ydoc, roomName, userName, userColor, serverUrl])
return { ydoc }
}Comment fonctionnent les fournisseurs : Les fournisseurs Yjs sont des adaptateurs de synchronisation enfichables. Le fournisseur WebSocket synchronise les modifications entre les clients connectés en temps réel. Le fournisseur IndexedDB persiste le document localement pour qu'il survive aux rafraîchissements de page et fonctionne hors ligne. Vous pouvez utiliser plusieurs fournisseurs simultanément — Yjs gère automatiquement la déduplication.
Étape 4 : Construction du composant éditeur collaboratif
Créez maintenant le composant éditeur en utilisant Tiptap, qui offre une intégration native avec Yjs :
// src/components/CollaborativeEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import * as Y from 'yjs'
import type { WebsocketProvider } from 'y-websocket'
interface CollaborativeEditorProps {
ydoc: Y.Doc
provider: WebsocketProvider
}
export function CollaborativeEditor({
ydoc,
provider,
}: CollaborativeEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
// Désactiver l'historique par défaut — Yjs gère annuler/rétablir
history: false,
}),
Collaboration.configure({
document: ydoc,
field: 'editor',
}),
CollaborationCursor.configure({
provider,
user: {
name: provider.awareness.getLocalState()?.user?.name ?? 'Anonyme',
color: provider.awareness.getLocalState()?.user?.color ?? '#3b82f6',
},
}),
],
})
if (!editor) {
return <div className="editor-loading">Chargement de l'éditeur...</div>
}
return (
<div className="editor-container">
<MenuBar editor={editor} />
<EditorContent editor={editor} className="editor-content" />
<ConnectionStatus provider={provider} />
</div>
)
}Étape 5 : Ajout de la barre d'outils et du statut de connexion
Créez une barre d'outils simple pour le formatage du texte :
// src/components/MenuBar.tsx
import type { Editor } from '@tiptap/react'
interface MenuBarProps {
editor: Editor
}
export function MenuBar({ editor }: MenuBarProps) {
return (
<div className="menu-bar">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''}
>
Gras
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'is-active' : ''}
>
Italique
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
>
Titre
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'is-active' : ''}
>
Liste
</button>
<button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
>
Annuler
</button>
<button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
>
Rétablir
</button>
</div>
)
}Ajoutez ensuite un indicateur de statut de connexion :
// src/components/ConnectionStatus.tsx
import { useEffect, useState } from 'react'
import type { WebsocketProvider } from 'y-websocket'
interface ConnectionStatusProps {
provider: WebsocketProvider
}
interface AwarenessUser {
name: string
color: string
}
export function ConnectionStatus({ provider }: ConnectionStatusProps) {
const [connected, setConnected] = useState(false)
const [peers, setPeers] = useState<AwarenessUser[]>([])
useEffect(() => {
const handleStatus = (event: { status: string }) => {
setConnected(event.status === 'connected')
}
const handleAwareness = () => {
const states = Array.from(provider.awareness.getStates().values())
const users = states
.filter((state) => state.user)
.map((state) => state.user as AwarenessUser)
setPeers(users)
}
provider.on('status', handleStatus)
provider.awareness.on('change', handleAwareness)
setConnected(provider.wsconnected)
handleAwareness()
return () => {
provider.off('status', handleStatus)
provider.awareness.off('change', handleAwareness)
}
}, [provider])
return (
<div className="connection-status">
<span className={`status-dot ${connected ? 'online' : 'offline'}`} />
<span>{connected ? 'Connecté' : 'Hors ligne (modifications sauvegardées localement)'}</span>
<div className="peer-list">
{peers.map((peer, i) => (
<span
key={i}
className="peer-badge"
style={{ backgroundColor: peer.color }}
>
{peer.name}
</span>
))}
</div>
</div>
)
}Étape 6 : Assemblage final
Mettez à jour le composant App principal pour tout connecter :
// src/App.tsx
import { useMemo, useState } from 'react'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
import { CollaborativeEditor } from './components/CollaborativeEditor'
import './App.css'
function getRandomColor(): string {
const colors = [
'#f43f5e', '#8b5cf6', '#3b82f6',
'#10b981', '#f59e0b', '#ec4899',
]
return colors[Math.floor(Math.random() * colors.length)]
}
export default function App() {
const [userName] = useState(
() => `Utilisateur-${Math.random().toString(36).slice(2, 6)}`
)
const [userColor] = useState(getRandomColor)
const roomName = 'collab-demo-room'
const { ydoc, provider } = useMemo(() => {
const doc = new Y.Doc()
const wsProvider = new WebsocketProvider(
'ws://localhost:1234',
roomName,
doc
)
wsProvider.awareness.setLocalStateField('user', {
name: userName,
color: userColor,
})
new IndexeddbPersistence(roomName, doc)
return { ydoc: doc, provider: wsProvider }
}, [roomName, userName, userColor])
return (
<div className="app">
<header className="app-header">
<h1>Éditeur Collaboratif</h1>
<p>Ouvrez cette page dans plusieurs onglets pour tester la collaboration en temps réel</p>
</header>
<CollaborativeEditor ydoc={ydoc} provider={provider} />
</div>
)
}Étape 7 : Configuration du serveur WebSocket
Yjs a besoin d'un serveur de signalisation pour que les clients puissent se découvrir et relayer les messages. Le paquet y-websocket est livré avec un serveur prêt à l'emploi :
npx y-websocketCela démarre un serveur WebSocket sur le port 1234. Pour un serveur personnalisé avec plus de contrôle :
// server.ts
import { WebSocketServer } from 'ws'
import { setupWSConnection } from 'y-websocket/bin/utils'
const wss = new WebSocketServer({ port: 1234 })
wss.on('connection', (ws, req) => {
const roomName = req.url?.slice(1) ?? 'default'
console.log(`Client connecté à la salle : ${roomName}`)
setupWSConnection(ws, req)
})
console.log('Serveur Yjs WebSocket en cours sur ws://localhost:1234')Important : Le serveur WebSocket est un relais — il ne stocke PAS les documents. Si tous les clients se déconnectent et que leurs données locales sont effacées, le document est perdu. En production, ajoutez la persistance côté serveur en utilisant y-leveldb ou y-mongodb-provider.
Étape 8 : Ajout de la persistance côté serveur
En production, vous voulez que le serveur persiste les documents pour qu'ils survivent même lorsque tous les clients se déconnectent :
// server-persistent.ts
import { WebSocketServer } from 'ws'
import { setupWSConnection, docs } from 'y-websocket/bin/utils'
import { LeveldbPersistence } from 'y-leveldb'
const wss = new WebSocketServer({ port: 1234 })
const ldb = new LeveldbPersistence('./yjs-data')
wss.on('connection', async (ws, req) => {
setupWSConnection(ws, req)
})
setInterval(async () => {
for (const [name, doc] of docs) {
await ldb.storeUpdate(name, Buffer.from(
require('yjs').encodeStateAsUpdate(doc)
))
}
}, 30_000)
console.log('Serveur Yjs persistant en cours sur ws://localhost:1234')Installez le paquet de persistance :
npm install y-leveldbÉtape 9 : Test de votre éditeur collaboratif
Démarrez le serveur et le serveur de développement :
# Terminal 1 : Démarrer le serveur Yjs WebSocket
npx y-websocket
# Terminal 2 : Démarrer le serveur de développement Vite
npm run devTestez maintenant la collaboration :
- Ouvrez deux onglets pointant vers
http://localhost:5173 - Tapez dans un onglet — vous devriez voir le texte apparaître instantanément dans l'autre
- Remarquez les curseurs colorés montrant la position de chaque utilisateur
- Ouvrez les DevTools et allez dans Application puis IndexedDB — vous verrez les données Yjs stockées localement
- Déconnectez votre réseau (activez le mode avion ou utilisez l'onglet Network des DevTools)
- Continuez à taper — les modifications sont sauvegardées localement
- Reconnectez-vous — les modifications des deux onglets fusionnent automatiquement
Au-delà du texte : structures de données partagées
Yjs ne se limite pas aux éditeurs de texte. Vous pouvez construire n'importe quoi de collaboratif en utilisant ses types de données partagés :
Liste de tâches collaborative
import * as Y from 'yjs'
const ydoc = new Y.Doc()
const ytodos = ydoc.getArray<Y.Map<unknown>>('todos')
function addTodo(text: string) {
const todo = new Y.Map()
todo.set('id', crypto.randomUUID())
todo.set('text', text)
todo.set('done', false)
todo.set('createdAt', Date.now())
ytodos.push([todo])
}
function toggleTodo(index: number) {
const todo = ytodos.get(index) as Y.Map<unknown>
todo.set('done', !todo.get('done'))
}Canevas de dessin collaboratif
import * as Y from 'yjs'
const ydoc = new Y.Doc()
const ystrokes = ydoc.getArray<Y.Map<unknown>>('strokes')
function addStroke(points: Array<{ x: number; y: number }>, color: string) {
const stroke = new Y.Map()
stroke.set('points', points)
stroke.set('color', color)
stroke.set('timestamp', Date.now())
ystrokes.push([stroke])
}Conseils de performance pour la production
1. Gestion de la taille des documents
Les documents Yjs grossissent avec le temps à mesure que les éditions s'accumulent. Pour les grands documents, utilisez le ramasse-miettes :
const ydoc = new Y.Doc({ gc: true }) // Activer le ramasse-miettes (par défaut)2. Limitation des mises à jour de conscience
const wsProvider = new WebsocketProvider(serverUrl, roomName, ydoc)
let awarenessTimeout: ReturnType<typeof setTimeout> | null = null
function throttledAwareness(field: string, value: unknown) {
if (awarenessTimeout) return
awarenessTimeout = setTimeout(() => {
wsProvider.awareness.setLocalStateField(field, value)
awarenessTimeout = null
}, 200)
}3. Chargement paresseux pour les grands documents
function useYjsDocument(roomName: string | null) {
const [ydoc, setYdoc] = useState<Y.Doc | null>(null)
useEffect(() => {
if (!roomName) return
const doc = new Y.Doc()
const provider = new WebsocketProvider('ws://localhost:1234', roomName, doc)
setYdoc(doc)
return () => {
provider.destroy()
doc.destroy()
setYdoc(null)
}
}, [roomName])
return ydoc
}Dépannage
Les modifications ne se synchronisent pas entre les onglets
- Vérifiez que le serveur WebSocket fonctionne sur le port 1234
- Vérifiez la console du navigateur pour les erreurs de connexion WebSocket
- Assurez-vous que les deux onglets utilisent le même
roomName
Données perdues après rafraîchissement de la page
- Assurez-vous que
y-indexeddbest correctement initialisé - Vérifiez IndexedDB dans les DevTools pour confirmer le stockage des données
- Le
roomNamedoit correspondre entre les sessions
Les noms des curseurs ne s'affichent pas
- Vérifiez que l'état de conscience est défini avant l'initialisation de l'éditeur
- Vérifiez que l'extension
CollaborationCursorest correctement configurée - Assurez-vous que le fournisseur est correctement transmis à l'extension de curseur
Comparaison des solutions Local-First
| Bibliothèque | Taille | Langage | Idéal pour |
|---|---|---|---|
| Yjs | 16 Ko | JS/TS | Édition de texte, besoins CRDT généraux |
| Automerge | 90 Ko | JS/Rust (WASM) | Synchronisation de documents JSON |
| Liveblocks | Hébergé | JS/TS | Collaboration gérée en temps réel |
| PartyKit | Hébergé | JS/TS | Salles sans serveur en temps réel |
| ElectricSQL | Variable | SQL | Synchronisation Postgres vers SQLite local |
Prochaines étapes
Maintenant que vous avez un éditeur collaboratif fonctionnel, voici comment l'étendre :
- Ajoutez l'authentification — intégrez votre fournisseur d'auth pour utiliser de vrais noms d'utilisateurs
- Ajoutez l'historique des versions — utilisez
Y.snapshot()pour sauvegarder des versions nommées - Déployez le serveur WebSocket — hébergez-le sur Railway, Fly.io ou un VPS derrière un reverse proxy
- Ajoutez les permissions — implémentez un mode lecture seule en filtrant les mises à jour entrantes
- Construisez plus de types partagés — tableaux kanban, feuilles de calcul ou outils de design collaboratifs
- Essayez WebRTC — remplacez le serveur WebSocket par la synchronisation pair-à-pair avec
y-webrtc
Conclusion
L'architecture local-first représente un changement fondamental dans la façon dont nous construisons les logiciels collaboratifs. En plaçant les données de l'utilisateur sur son appareil d'abord et en synchronisant en arrière-plan, nous obtenons :
- Réactivité instantanée — pas d'attente pour les allers-retours réseau
- Support hors ligne complet — l'application fonctionne partout, toujours
- Collaboration sans conflits — les CRDTs garantissent la convergence
- Propriété utilisateur — les données vivent sur l'appareil de l'utilisateur
Yjs rend cela accessible avec une bibliothèque petite et performante et un écosystème riche de fournisseurs et d'intégrations. Que vous construisiez un éditeur de documents, un outil de gestion de projet ou un tableau blanc collaboratif, l'approche local-first avec les CRDTs mérite d'être explorée pour votre prochain projet.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Construire une application full-stack en temps réel avec Convex et Next.js 15
Apprenez à construire une application full-stack en temps réel avec Convex et Next.js 15. Ce tutoriel couvre la conception de schémas, les requêtes, les mutations, les abonnements en temps réel, l'authentification et le téléchargement de fichiers — le tout avec une sécurité de types de bout en bout.

Authentifier votre application Next.js 15 avec Auth.js v5 : Email, OAuth et contrôle des rôles
Apprenez à ajouter une authentification prête pour la production à votre application Next.js 15 avec Auth.js v5. Ce guide complet couvre Google OAuth, les identifiants email/mot de passe, les routes protégées, le middleware et le contrôle d'accès basé sur les rôles.

Construire un Chatbot IA Local avec Ollama et Next.js : Guide Complet
Construisez un chatbot IA privé fonctionnant entièrement sur votre machine locale avec Ollama et Next.js. Ce tutoriel pratique couvre l'installation, le streaming des réponses, la sélection de modèles et le déploiement d'une interface de chat prête pour la production — le tout sans envoyer de données dans le cloud.