Skip to content

Writes

How to do local writes and write-path sync with Electric.

Includes patterns for online writes, optimistic state, shared persistent optimistic state and through-the-database sync.

With accompanying code in the write-patterns example.

Local writes with Electric

Electric does read-path sync. It syncs data out-of Postgres, into local apps and services.

Electric does not do write-path sync. It doesn't provide (or prescribe) a built-in solution for getting data back into Postgres from local apps and services.

So how do you handle local writes with Electric?

Well, the design philosophy behind Electric is to be composable and integrate with your existing stack. So, just as you can sync into any client you like, you can implement writes in any way you like, using a variety of different patterns.

Patterns

This guide describes four different patterns for handling writes with Electric. It shows code examples and discusses trade-offs to consider when choosing between them.

  1. online writes
  2. optimistic state
  3. shared persistent optimistic state
  4. through-the-database sync

All of the patterns use Electric for the read-path sync (i.e.: to sync data from Postgres into the local app) and use a different approach for the write-path (i.e.: how they handle local writes and get data from the local app back into Postgres).

They are introduced in order of simplicity. So the simplest and easiest to implement first and the more powerful but more complex patterns further down ‐ where you may prefer to reach for a framework rather than implement yourself.

Write-patterns example on GitHub

This guide has an accompanying write-patterns example on GitHub. This implements each of the patterns described below and combines them into a single React application.

You can see the example running online at write-patterns.examples.electric-sql.com

1. Online writes

(source code)

The first pattern is simply to use online writes.

Not every app needs local, offline writes. Some apps are read-only. Some only have occasional writes or are fine requiring the user to be online in order to edit data.

In this case, you can combine Electric sync with web service calls to send writes to a server. For example, the implementation in patterns/1-online-writes runs a simple Node server (in api.js) and uses REST API calls for writes:

tsx
import React from 'react'
import { v4 as uuidv4 } from 'uuid'
import { useShape } from '@electric-sql/react'

import api from '../../shared/app/client'
import { ELECTRIC_URL, envParams } from '../../shared/app/config'

type Todo = {
  id: string
  title: string
  completed: boolean
  created_at: Date
}

export default function OnlineWrites() {
  // Use Electric's `useShape` hook to sync data from Postgres
  // into a React state variable.
  const { isLoading, data } = useShape<Todo>({
    url: `${ELECTRIC_URL}/v1/shape`,
    params: {
      table: 'todos',
      ...envParams,
    },
    parser: {
      timestamptz: (value: string) => new Date(value),
    },
  })

  const todos = data ? data.sort((a, b) => +a.created_at - +b.created_at) : []

  // Handle user input events by making requests to the backend
  // API to create, update and delete todos.

  async function createTodo(event: React.FormEvent) {
    event.preventDefault()

    const form = event.target as HTMLFormElement
    const formData = new FormData(form)
    const title = formData.get('todo') as string

    const path = '/todos'
    const data = {
      id: uuidv4(),
      title: title,
      created_at: new Date(),
    }

    await api.request(path, 'POST', data)

    form.reset()
  }

  async function updateTodo(todo: Todo) {
    const path = `/todos/${todo.id}`

    const data = {
      completed: !todo.completed,
    }

    await api.request(path, 'PUT', data)
  }

  async function deleteTodo(event: React.MouseEvent, todo: Todo) {
    event.preventDefault()

    const path = `/todos/${todo.id}`

    await api.request(path, 'DELETE')
  }

  if (isLoading) {
    return <div className="loading">Loading &hellip;</div>
  }

  // prettier-ignore
  return (
    <div id="online-writes" className="example">
      <h3>1. Online writes</h3>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <label>
              <input type="checkbox" checked={todo.completed}
                  onChange={() => updateTodo(todo)}
              />
              <span className={`title ${ todo.completed ? 'completed' : '' }`}>
                { todo.title }
              </span>
            </label>
            <a href="#delete" className="close"
                onClick={(event) => deleteTodo(event, todo)}>
              &#x2715;</a>
          </li>
        ))}
        {todos.length === 0 && (
          <li>All done 🎉</li>
        )}
      </ul>
      <form onSubmit={createTodo}>
        <input type="text" name="todo"
            placeholder="Type here &hellip;"
            required
        />
        <button type="submit">
          Add
        </button>
      </form>
    </div>
  )
}

Benefits

Online writes are very simple to implement with your existing API. The pattern allows you to create apps that are fast and available offline for reading data.

Good use-cases include:

  • live dashboards, data analytics and data visualisation
  • AI applications that generate embeddings in the cloud
  • systems where writes require online integration anyway, e.g.: making payments

Drawbacks

You have the network on the write path. This can be slow and laggy with the user left watching loading spinners. The UI doesn't update until the server responds. Applications won't work offline.

2. Optimistic state

(source code)

The second pattern extends the online pattern above with support for local offline writes with simple optimistic state.

Optimistic state is state that you display "optimistically" whilst waiting for an asynchronous operation, like sending data to a server, to complete. This allows local writes to be accepted when offline and displayed immediately to the user, by merging the synced state with the optimistic state when rendering.

When the writes do succeed, they are automatically synced back to the app via Electric and the local optimistic state can be discarded.

The example implementation in patterns/2-optimistic-state uses the same REST API calls as the online example above, along with React's built in useOptimistic hook to apply and discard the optimistic state.

tsx
import React, { useOptimistic, useTransition } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { matchBy, matchStream } from '@electric-sql/experimental'
import { useShape } from '@electric-sql/react'

import api from '../../shared/app/client'
import { ELECTRIC_URL, envParams } from '../../shared/app/config'

type Todo = {
  id: string
  title: string
  completed: boolean
  created_at: Date
}
type PartialTodo = Partial<Todo> & {
  id: string
}

type Write = {
  operation: 'insert' | 'update' | 'delete'
  value: PartialTodo
}

export default function OptimisticState() {
  const [isPending, startTransition] = useTransition()

  // Use Electric's `useShape` hook to sync data from Postgres
  // into a React state variable.
  //
  // Note that we also unpack the `stream` from the useShape
  // return value, so that we can monitor it below to detect
  // local writes syncing back from the server.
  const { isLoading, data, stream } = useShape<Todo>({
    url: `${ELECTRIC_URL}/v1/shape`,
    params: {
      table: 'todos',
      ...envParams,
    },
    parser: {
      timestamptz: (value: string) => new Date(value),
    },
  })

  const sorted = data ? data.sort((a, b) => +a.created_at - +b.created_at) : []

  // Use React's built in `useOptimistic` hook. This provides
  // a mechanism to apply local optimistic state whilst writes
  // are being sent-to and syncing-back-from the server.
  const [todos, addOptimisticState] = useOptimistic(
    sorted,
    (synced: Todo[], { operation, value }: Write) => {
      switch (operation) {
        case 'insert':
          return synced.some((todo) => todo.id === value.id)
            ? synced
            : [...synced, value as Todo]

        case 'update':
          return synced.map((todo) =>
            todo.id === value.id ? { ...todo, ...value } : todo
          )

        case 'delete':
          return synced.filter((todo) => todo.id !== value.id)
      }
    }
  )

  // These are the same event handler functions from the online
  // example, extended with `startTransition` -> `addOptimisticState`
  // to apply local optimistic state.
  //
  // Note that the local state is applied:
  //
  // 1. whilst the HTTP request is being made to the API server; and
  // 2. until the write syncs back through the Electric shape stream
  //
  // This is slightly different from most optimistic state examples
  // because we wait for the sync as well as the api request.

  async function createTodo(event: React.FormEvent) {
    event.preventDefault()

    const form = event.target as HTMLFormElement
    const formData = new FormData(form)
    const title = formData.get('todo') as string

    const path = '/todos'
    const data = {
      id: uuidv4(),
      title: title,
      created_at: new Date(),
      completed: false,
    }

    startTransition(async () => {
      addOptimisticState({ operation: 'insert', value: data })

      const fetchPromise = api.request(path, 'POST', data)
      const syncPromise = matchStream(
        stream,
        ['insert'],
        matchBy('id', data.id)
      )

      await Promise.all([fetchPromise, syncPromise])
    })

    form.reset()
  }

  async function updateTodo(todo: Todo) {
    const { id, completed } = todo

    const path = `/todos/${id}`
    const data = {
      id,
      completed: !completed,
    }

    startTransition(async () => {
      addOptimisticState({ operation: 'update', value: data })

      const fetchPromise = api.request(path, 'PUT', data)
      const syncPromise = matchStream(stream, ['update'], matchBy('id', id))

      await Promise.all([fetchPromise, syncPromise])
    })
  }

  async function deleteTodo(event: React.MouseEvent, todo: Todo) {
    event.preventDefault()

    const { id } = todo

    const path = `/todos/${id}`

    startTransition(async () => {
      addOptimisticState({ operation: 'delete', value: { id } })

      const fetchPromise = api.request(path, 'DELETE')
      const syncPromise = matchStream(stream, ['delete'], matchBy('id', id))

      await Promise.all([fetchPromise, syncPromise])
    })
  }

  if (isLoading) {
    return <div className="loading">Loading &hellip;</div>
  }

  // The template below the heading is identical to the other patterns.

  // prettier-ignore
  return (
    <div id="optimistic-state" className="example">
      <h3>
        <span className="title">
          2. Optimistic state
        </span>
        <span className={isPending ? 'pending' : 'pending hidden'} />
      </h3>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <label>
              <input type="checkbox" checked={todo.completed}
                  onChange={() => updateTodo(todo)}
              />
              <span className={`title ${ todo.completed ? 'completed' : '' }`}>
                { todo.title }
              </span>
            </label>
            <a href="#delete" className="close"
                onClick={(event) => deleteTodo(event, todo)}>
              &#x2715;</a>
          </li>
        ))}
        {todos.length === 0 && (
          <li>All done 🎉</li>
        )}
      </ul>
      <form onSubmit={createTodo}>
        <input type="text" name="todo"
            placeholder="Type here &hellip;"
            required
        />
        <button type="submit">
          Add
        </button>
      </form>
    </div>
  )
}

Benefits

Using optimistic state allows you to take the network off the write path and allows you to create apps that are fast and available offline for both reading and writing data.

The pattern is simple to implement. You can handle writes using your existing API.

Good use-cases include:

  • management apps and interactive dashboards
  • apps that want to feel fast and avoid loading spinners on write
  • mobile apps that want to be resilient to patchy connectivity

Drawbacks

This example illustrates a "simple" approach where the optimistic state:

  1. is component-scoped, i.e.: is only available within the component that makes the write
  2. is not persisted

This means that other components may display inconsistent information and users may be confused by the optimistic state dissapearing if they unmount the component or reload the page. These limitations are addressed by the more comprehensive approach in the next pattern.

3. Shared persistent

(source code)

The third pattern extends the second pattern above by storing the optimistic state in a shared, persistent local store.

This makes offline writes more resilient and avoids components getting out of sync. It's a compelling point in the design space: providing good UX and DX without introducing too much complexity or any heavy dependencies.

This pattern can be implemented with a variety of client-side state management and storage mechanisms. This example in patterns/3-shared-persistent uses valtio with localStorage for a shared, persistent, reactive store. This allows us to keep the code very similar to the simple optimistic state example above (with a valtio useSnapshot and plain reduce function replacing useOptimistic).

tsx
import React, { useTransition } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { subscribe, useSnapshot } from 'valtio'
import { proxyMap } from 'valtio/utils'

import { type Operation, ShapeStream } from '@electric-sql/client'
import { matchBy, matchStream } from '@electric-sql/experimental'
import { useShape } from '@electric-sql/react'

import api from '../../shared/app/client'
import { ELECTRIC_URL, envParams } from '../../shared/app/config'

const KEY = 'electric-sql/examples/write-patterns/shared-persistent'

type Todo = {
  id: string
  title: string
  completed: boolean
  created_at: Date
}
type PartialTodo = Partial<Todo> & {
  id: string
}

type LocalWrite = {
  id: string
  operation: Operation
  value: PartialTodo
}

// Define a shared, persistent, reactive store for local optimistic state.
const optimisticState = proxyMap<string, LocalWrite>(
  JSON.parse(localStorage.getItem(KEY) || '[]')
)
subscribe(optimisticState, () => {
  localStorage.setItem(KEY, JSON.stringify([...optimisticState]))
})

/*
 * Add a local write to the optimistic state
 */
function addLocalWrite(operation: Operation, value: PartialTodo): LocalWrite {
  const id = uuidv4()

  const write: LocalWrite = {
    id,
    operation,
    value,
  }

  optimisticState.set(id, write)

  return write
}

/*
 * Subscribe to the shape `stream` until the local write syncs back through it.
 * At which point, delete the local write from the optimistic state.
 */
async function matchWrite(
  stream: ShapeStream<Todo>,
  write: LocalWrite
): Promise<void> {
  const { operation, value } = write

  const matchFn =
    operation === 'delete'
      ? matchBy('id', value.id)
      : matchBy('write_id', write.id)

  try {
    await matchStream(stream, [operation], matchFn)
  } catch (_err) {
    return
  }

  optimisticState.delete(write.id)
}

/*
 * Make an HTTP request to send the write to the API server.
 * If the request fails, delete the local write from the optimistic state.
 * If it succeeds, return the `txid` of the write from the response data.
 */
async function sendRequest(
  path: string,
  method: string,
  { id, value }: LocalWrite
): Promise<void> {
  const data = {
    ...value,
    write_id: id,
  }

  let response: Response | undefined
  try {
    response = await api.request(path, method, data)
  } catch (_err) {
    // ignore
  }

  if (response === undefined || !response.ok) {
    optimisticState.delete(id)
  }
}

export default function SharedPersistent() {
  const [isPending, startTransition] = useTransition()

  // Use Electric's `useShape` hook to sync data from Postgres.
  const { isLoading, data, stream } = useShape<Todo>({
    url: `${ELECTRIC_URL}/v1/shape`,
    params: {
      table: 'todos',
      ...envParams,
    },
    parser: {
      timestamptz: (value: string) => new Date(value),
    },
  })

  const sorted = data ? data.sort((a, b) => +a.created_at - +b.created_at) : []

  // Get the local optimistic state.
  const localWrites = useSnapshot<Map<string, LocalWrite>>(optimisticState)

  const computeOptimisticState = (
    synced: Todo[],
    writes: LocalWrite[]
  ): Todo[] => {
    return writes.reduce(
      (synced: Todo[], { operation, value }: LocalWrite): Todo[] => {
        switch (operation) {
          case 'insert':
            return [...synced, value as Todo]
          case 'update':
            return synced.map((todo) =>
              todo.id === value.id ? { ...todo, ...value } : todo
            )
          case 'delete':
            return synced.filter((todo) => todo.id !== value.id)
          default:
            return synced
        }
      },
      synced
    )
  }

  const todos = computeOptimisticState(sorted, [...localWrites.values()])

  // These are the same event handler functions from the previous optimistic
  // state pattern, adapted to add the state to the shared, persistent store.

  async function createTodo(event: React.FormEvent) {
    event.preventDefault()

    const form = event.target as HTMLFormElement
    const formData = new FormData(form)
    const title = formData.get('todo') as string

    const path = '/todos'
    const data = {
      id: uuidv4(),
      title: title,
      completed: false,
      created_at: new Date(),
    }

    startTransition(async () => {
      const write = addLocalWrite('insert', data)
      const fetchPromise = sendRequest(path, 'POST', write)
      const syncPromise = matchWrite(stream, write)

      await Promise.all([fetchPromise, syncPromise])
    })

    form.reset()
  }

  async function updateTodo(todo: Todo) {
    const { id, completed } = todo

    const path = `/todos/${id}`
    const data = {
      id,
      completed: !completed,
    }

    startTransition(async () => {
      const write = addLocalWrite('update', data)
      const fetchPromise = sendRequest(path, 'PUT', write)
      const syncPromise = matchWrite(stream, write)

      await Promise.all([fetchPromise, syncPromise])
    })
  }

  async function deleteTodo(event: React.MouseEvent, todo: Todo) {
    event.preventDefault()

    const { id } = todo

    const path = `/todos/${id}`
    const data = {
      id,
    }

    startTransition(async () => {
      const write = addLocalWrite('delete', data)
      const fetchPromise = sendRequest(path, 'DELETE', write)
      const syncPromise = matchWrite(stream, write)

      await Promise.all([fetchPromise, syncPromise])
    })
  }

  if (isLoading) {
    return <div className="loading">Loading &hellip;</div>
  }

  // The template below the heading is identical to the other patterns.

  // prettier-ignore
  return (
    <div id="optimistic-state" className="example">
      <h3>
        <span className="title">
          3. Shared persistent
        </span>
        <span className={isPending ? 'pending' : 'pending hidden'} />
      </h3>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <label>
              <input type="checkbox" checked={todo.completed}
                  onChange={() => updateTodo(todo)}
              />
              <span className={`title ${ todo.completed ? 'completed' : '' }`}>
                { todo.title }
              </span>
            </label>
            <a href="#delete" className="close"
                onClick={(event) => deleteTodo(event, todo)}>
              &#x2715;</a>
          </li>
        ))}
        {todos.length === 0 && (
          <li>All done 🎉</li>
        )}
      </ul>
      <form onSubmit={createTodo}>
        <input type="text" name="todo"
            placeholder="Type here &hellip;"
            required
        />
        <button type="submit">
          Add
        </button>
      </form>
    </div>
  )
}

Benefits

This is a powerful and pragmatic pattern, occupying a compelling point in the design space. It's relatively simple to implement.

Persisting optimistic state makes local writes more resilient. Storing optimistic state in a shared store allows all your components to see and react to it. This avoids the weaknesses with ephemoral, component-scoped optimistic state and makes this pattern more suitable for more complex, real world apps.

Seperating immutable synced state from mutable local state also makes it easy to reason about and implement rollback strategies. Worst case, you can always just wipe the local state and/or re-sync the server state, without having to unpick some kind of merged mutable store.

Good use-cases include:

  • building local-first software
  • interactive SaaS applications
  • collaboration and authoring software

Drawbacks

Combining data on-read makes local reads slightly slower. Whilst a persistent local store is used for optimistic state, writes are still made via an API. This can often be helpful and pragmatic, allowing you to re-use your existing API. However, you may prefer to avoid this, with a purer local-first approach based on syncing through a local embedded database.

Implementation notes

The merge logic in the matchWrite function differs from the previous optimistic state example in that it supports rebasing local optimistic state on concurrent updates from other users.

The entrypoint for handling rollbacks has the local write context available. So it's able to rollback individual writes, rather than wiping the whole local state.

Because it has the shared store available, it would also be possible to extend this to implement more sophisticated strategies. Such as also removing other local writes that causally depended-on or were related-to the rejected write.

4. Through the database sync

(source code)

The fourth pattern extends the concept of shared, persistent optimistic state all the way to an embedded local database.

This provides a pure local-first experience, where the application code talks directly to a local database and changes sync automatically in the background. This "power" comes at the cost of increased complexity in the form of an embedded database, complex local schema and loss of context when handling rollbacks.

The example in patterns/4-through-the-db uses PGlite to store both synced and local optimistic state.

Specifically, it:

  1. syncs data into an immutable todos_synced table
  2. persists optimistic state in a shadow todos_local table; and
  3. combines the two on read using a todos view.

For the write path sync it:

  1. uses INSTEAD OF triggers to
    • redirect writes made to the todos view to the todos_local table
    • keep a log of local writes in a changes table
  2. uses NOTIFY to drive a sync utility
    • which sends the changes to the server

Through this, the implementation:

  • automatically manages optimistic state lifecycle
  • presents a single table interface for reads and writes
  • auto-syncs the local writes to the server

The application code code in index.tsx stays very simple. Most of the complexity is abstracted into the local database schema, defined in local-schema.sql. The write-path sync utility in sync.ts handles sending data to the server.

These are shown in the three tabs below:

tsx
import React, { useEffect, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'

import {
  PGliteProvider,
  useLiveQuery,
  usePGlite,
} from '@electric-sql/pglite-react'
import { type PGliteWithLive } from '@electric-sql/pglite/live'

import loadPGlite from './db'
import ChangeLogSynchronizer from './sync'

type Todo = {
  id: string
  title: string
  completed: boolean
  created_at: Date
}

/*
 * Setup the local PGlite database, with automatic change detection and syncing.
 *
 * See `./local-schema.sql` for the local database schema, including view
 * and trigger machinery.
 *
 * See `./sync.ts` for the write-path sync utility, which listens to changes
 * using pg_notify, as per https://pglite.dev/docs/api#listen
 */
export default function Wrapper() {
  const [db, setDb] = useState<PGliteWithLive>()

  useEffect(() => {
    let isMounted = true
    let writePathSync: ChangeLogSynchronizer

    async function init() {
      const pglite = await loadPGlite()

      if (!isMounted) {
        return
      }

      writePathSync = new ChangeLogSynchronizer(pglite)
      writePathSync.start()

      setDb(pglite)
    }

    init()

    return () => {
      isMounted = false

      if (writePathSync !== undefined) {
        writePathSync.stop()
      }
    }
  }, [])

  if (db === undefined) {
    return <div className="loading">Loading &hellip;</div>
  }

  return (
    <PGliteProvider db={db}>
      <ThroughTheDB />
    </PGliteProvider>
  )
}

function ThroughTheDB() {
  const db = usePGlite()
  const results = useLiveQuery<Todo>('SELECT * FROM todos ORDER BY created_at')

  async function createTodo(event: React.FormEvent) {
    event.preventDefault()

    const form = event.target as HTMLFormElement
    const formData = new FormData(form)
    const title = formData.get('todo') as string

    await db.sql`
      INSERT INTO todos (
        id,
        title,
        completed,
        created_at
      )
      VALUES (
        ${uuidv4()},
        ${title},
        ${false},
        ${new Date()}
      )
    `

    form.reset()
  }

  async function updateTodo(todo: Todo) {
    const { id, completed } = todo

    await db.sql`
      UPDATE todos
        SET completed = ${!completed}
        WHERE id = ${id}
    `
  }

  async function deleteTodo(event: React.MouseEvent, todo: Todo) {
    event.preventDefault()

    await db.sql`
      DELETE FROM todos
        WHERE id = ${todo.id}
    `
  }

  if (results === undefined) {
    return <div className="loading">Loading &hellip;</div>
  }

  const todos = results.rows

  // The template below the heading is identical to the other patterns.

  // prettier-ignore
  return (
    <div id="optimistic-state" className="example">
      <h3>
        <span className="title">
          4. Through the DB
        </span>
      </h3>
      <ul>
        {todos.map((todo: Todo) => (
          <li key={todo.id}>
            <label>
              <input type="checkbox" checked={todo.completed}
                  onChange={() => updateTodo(todo)}
              />
              <span className={`title ${ todo.completed ? 'completed' : '' }`}>
                { todo.title }
              </span>
            </label>
            <a href="#delete" className="close"
                onClick={(event) => deleteTodo(event, todo)}>
              &#x2715;</a>
          </li>
        ))}
        {todos.length === 0 && (
          <li>All done 🎉</li>
        )}
      </ul>
      <form onSubmit={createTodo}>
        <input type="text" name="todo"
            placeholder="Type here &hellip;"
            required
        />
        <button type="submit">
          Add
        </button>
      </form>
    </div>
  )
}

Benefits

This provides full offline support, shared optimistic state and allows your components to interact purely with the local database, rather than coding over the network. Data fetching and sending is abstracted away behind Electric (for reads) and the sync utility processing the change log (for writes).

Good use-cases include:

  • building local-first software
  • mobile and desktop applications
  • collaboration and authoring software

Drawbacks

Using a local embedded database adds quite a heavy dependency to your app. The shadow table and trigger machinery complicate your client side schema definition.

Syncing changes in the background complicates any potential rollback handling. In the shared persistent optimistic state pattern, you can detect a write being rejected by the server whilst in context, still handling user input. With through-the-database sync, this context is harder to reconstruct.

Implementation notes

The merge logic in the delete_local_on_synced_insert_and_update_trigger in ./local-schema.sql supports rebasing local optimistic state on concurrent updates from other users.

The rollback strategy in the rollback method of the ChangeLogSynchronizer in ./sync.ts is very naive: clearing all local state and writes in the event of any write being rejected by the server. You may want to implement a more nuanced strategy. For example, to provide information to the user about what is happening and / or minimise data loss by only clearing local-state that's causally dependent on a rejected write.

This opens the door to a lot of complexity that may best be addressed by using an existing framework or one of the simpler patterns.

Advanced

This is an advanced section.

You don't need to engage with these complexities to get started with Electric.

There are two key complexities introduced by handling offline writes or local writes with optimistic state:

  1. merge logic when receiving synced state from the server
  2. handling rollbacks when writes are rejected

Merge logic

When a change syncs in over the Electric replication stream, the application has to decide how to handle any overlapping optimistic state. This can be complicated by concurrency, when changes syncing in may be made by other users (or devices, or even tabs). The third and fourth examples both demonstrate approaches to rebasing the local state on the synced state, rather than just naively clearing the local state, in order to preserve local changes.

Linearlite is another example of through-the-DB sync with more sophisticated merge logic.

Rollbacks

If an offline write is rejected by the server, the local application needs to find some way to revert the local state and potentially notify the user. A baseline approach can be to clear all local state if any write is rejected. More sophisticated and forgiving strategies are possible, such as:

  • marking local writes as rejected and displaying for manual conflict resolution
  • only clearing the set of writes that are causally dependent on the rejected operation

One consideration is the indirection between making a write and handling a rollback. When sending write operations directly to an API, your application code can handle a rollback with the write context still available. When syncing through the database, the original write context is much harder to reconstruct.

YAGNI

Adam Wiggins, one of the authors of the local-first paper, developed a canvas-based thinking tool called Muse, explicitly designed to support concurrent, collaborative editing of an infinite canvas. Having operated at scale with a large user base, one of his main findings reported back at the first local-first meetup in Berlin in 2023 was that in reality, conflicts are extremely rare and can be mitigated well by strategies like presence.

If you're crafting a highly concurrent, collaborative experience, you may want to engage with the complexities of merge logic and rebasing local state. However, blunt strategies like those illustrated by the patterns in this guide can be much easier to implement and reason about — and are perfectly serviceable for many applications.

Tools

Below we list some useful tools that work well for implementing writes with Electric.

Libraries

Frameworks

See also the list of local-first projects on the alternatives page.