Construire des applications collaboratives Local-First avec Yjs et React

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

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 :

TypeFonctionnementExemple
Basé sur l'état (CvRDT)Fusionne des instantanés d'état completsG-Counter, LWW-Register
Basé sur les opérations (CmRDT)Envoie et applique des opérations individuellesYjs, 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-editor

Installez 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-cursor

Voici ce que fait chaque paquet :

PaquetRôle
yjsBibliothèque CRDT de base
y-websocketFournisseur WebSocket pour la synchronisation en temps réel
y-indexeddbFournisseur IndexedDB pour la persistance hors ligne
@tiptap/reactFramework d'éditeur de texte riche pour React
@tiptap/starter-kitExtensions Tiptap essentielles (gras, italique, titres)
@tiptap/extension-collaborationIntégration Yjs pour Tiptap
@tiptap/extension-collaboration-cursorAfficher 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-websocket

Cela 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 dev

Testez maintenant la collaboration :

  1. Ouvrez deux onglets pointant vers http://localhost:5173
  2. Tapez dans un onglet — vous devriez voir le texte apparaître instantanément dans l'autre
  3. Remarquez les curseurs colorés montrant la position de chaque utilisateur
  4. Ouvrez les DevTools et allez dans Application puis IndexedDB — vous verrez les données Yjs stockées localement
  5. Déconnectez votre réseau (activez le mode avion ou utilisez l'onglet Network des DevTools)
  6. Continuez à taper — les modifications sont sauvegardées localement
  7. 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-indexeddb est correctement initialisé
  • Vérifiez IndexedDB dans les DevTools pour confirmer le stockage des données
  • Le roomName doit 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 CollaborationCursor est correctement configurée
  • Assurez-vous que le fournisseur est correctement transmis à l'extension de curseur

Comparaison des solutions Local-First

BibliothèqueTailleLangageIdéal pour
Yjs16 KoJS/TSÉdition de texte, besoins CRDT généraux
Automerge90 KoJS/Rust (WASM)Synchronisation de documents JSON
LiveblocksHébergéJS/TSCollaboration gérée en temps réel
PartyKitHébergéJS/TSSalles sans serveur en temps réel
ElectricSQLVariableSQLSynchronisation 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.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Maîtriser les SMS Twilio : Guide du débutant pour la messagerie Node.js en entreprise.

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.

30 min read·

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.

25 min read·