Designing Local-First features for Hologram - what's your dream DX?

I think that Hologram as it stands today already does a world-class job separating client-side and server-side concerns. Extending that principle into managing offline data is how I would approach this problem.

Let me explain the DX I wish for with some code examples. (Caveat: I will include Ash-like snippets in my examples, but that’s primarily because I work in Ash every day. I am much more comfortable expressing myself in it)

Imagine a simple Hologram page. Right now, opening and closing a ticket works this way:

defmodule MyApp.TicketListPage do
  use Hologram.Page

  # ... route, layout, init, template omitted for brevity ...

  def action(:open_ticket, _params, component) do
    subject = component.state.new_subject
    component
    |> put_state(:new_subject, "")
    |> put_command(:create_ticket, %{subject: subject})
  end

  def action(:close_ticket, params, component) do
    component
    |> put_command(:update_ticket, %{id: params.id, status: :closed})
  end

  def command(:create_ticket, params, server) do
    case do_open_ticket(params.subject) do
      {:ok, ticket} -> put_action(server, :ticket_created, %{ticket: ticket})
      {:error, _} -> put_action(server, :create_failed, %{})
    end
  end

  def command(:update_ticket, params, server) do
    case do_close_ticket(params.id) do
      {:ok, ticket} -> put_action(server, :ticket_updated, %{ticket: ticket})
      {:error, _} -> put_action(server, :update_failed, %{})
    end
  end

  defp do_open_ticket(subject) do
    # Does something... Starts an Oban job, calls an external API, sends an email, etc
    MyApp.Support.Ticket.open(subject)
    ...
  end

  defp do_close_ticket(id) do
    # Also does some very important server-side things
    MyApp.Support.Ticket.close(id)
    ...
  end
end

With a ticket resource that looks like this:

defmodule MyApp.Support.Ticket do
  use Ash.Resource,
    domain: MyApp.Support,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer]

  postgres do
    table "tickets"
    repo MyApp.Repo
  end

  attributes do
    uuid_primary_key :id

    attribute :subject, :string, allow_nil?: false, public?: true
    attribute :status, :atom do
      constraints one_of: [:open, :closed]
      default :open
      allow_nil? false
    end
  end

  relationships do
    belongs_to :representative, MyApp.Support.Representative
  end

  policies do
    policy action_type(:read) do
      authorize_if expr(representative_id == ^actor(:id))
    end

    policy action_type(:create) do
      authorize_if always()
    end

    policy action(:close) do
      authorize_if expr(representative_id == ^actor(:id))
    end
  end

  actions do
    defaults [:read]

    create :open do
      accept [:subject]
    end
    
    update :close do
      accept []
      argument :id, :uuid, allow_nil?: false
      validate attribute_does_not_equal(:status, :closed) do
        message "Ticket is already closed"
      end
      change set_attribute(:status, :closed)
    end
  end

  code_interface do
    define :open, args: [:subject]
    define :close, args: [:id]
  end
end

In my dream DX, upgrading what we have today to a local first app will only need these changes:

Configure an extension provided by Hologram:

defmodule MyApp.Support.Ticket do
  use Ash.Resource,
    domain: MyApp.Support,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer],
    extensions: [Hologram.Extension.IndexedDb] # add the local datastore you want to use

  # ... everything else stays the same ...

  indexed_db do
    store "tickets"
    scope :authorized  # sync everything the actor is authorized to read
    resolution_strategy :last_write_wins # can be the simple strategy for a start.  future versions can even allow anonymous functions for custom strategies
  end
end

On the page, actions now write to the local store first and then dispatch commands:

defmodule MyApp.TicketListPage do
  use Hologram.Page

  # ... route, layout, template omitted for brevity ...

  def init(_params, component, _server) do
    # Reads come from the local store -- no server round-trip, no spinner.
    tickets = MyApp.Support.Ticket.read!()
    put_state(component, %{tickets: tickets, new_subject: ""})
  end

  def action(:open_ticket, _params, component) do
    subject = component.state.new_subject

    # 1. Write to local store immediately (optimistic update)
    {:ok, ticket} = MyApp.Support.Ticket.open(subject)
    tickets = MyApp.Support.Ticket.read!()

    component
    |> put_state(%{tickets: tickets, new_subject: ""})
    |> put_command(:create_ticket, %{subject: subject})
    # 2. Command syncs to server in the background
  end

  def action(:close_ticket, params, component) do
    # Write to local store immediately
    {:ok, _ticket} = MyApp.Support.Ticket.close(params.id)
    tickets = MyApp.Support.Ticket.read!()

    component
    |> put_state(:tickets, tickets)
    |> put_command(:update_ticket, %{id: params.id})
  end

  # Commands stay exactly the same -- they still do the important
  # server-side work (Oban jobs, external APIs, etc.)
  def command(:create_ticket, params, server) do
    case do_open_ticket(params.subject) do
      {:ok, ticket} -> put_action(server, :ticket_created, %{ticket: ticket})
      {:error, _} -> put_action(server, :create_failed, %{})
    end
  end

  def command(:update_ticket, params, server) do
    case do_close_ticket(params.id) do
      {:ok, ticket} -> put_action(server, :ticket_updated, %{ticket: ticket})
      {:error, _} -> put_action(server, :update_failed, %{})
    end
  end

  defp do_open_ticket(subject) do
    MyApp.Support.Ticket.open(subject)
    ...
  end

  defp do_close_ticket(id) do
    MyApp.Support.Ticket.close(id)
    ...
  end
end

This approach is beautiful to me because it’s declarative and explicit, and it preserves the mental model that the developer already works with (client-side concerns happen in actions and server-side concerns in commands, no magic). Calling MyApp.Support.Ticket.open in both the action and the command might initially seem redundant. But to me, it is explicit. It’s not redundant.

In addition, this approach suddenly unlocks several new possibilities! Local-first, local-only, local-never (server-only), @woylie‘s vision of 3-way-sync, or any other permutation of interest, all by configuring resource extensions and choosing where to call a write function:

  • Local-only: Action writes to local store. No command dispatched. Data never leaves the client. Use case: drafts, preferences, UI state.
  • Local-first: Action writes to local store AND dispatches a command. UI updates instantly, server syncs in the background. Use case: most app data. This is what the example above does.
  • Server-only: Action dispatches a command without writing locally. Waits for server response. Use case: payments, sensitive operations.
  • Three-way sync: Same resource configured with multiple extensions (e.g., IndexedDB in the browser, SQLite on the filesystem, Postgres on the server). Each syncs via the same command queue. Use case: @woylie’s vision of PWA + desktop + self-hosted server.

The wiring to achieve this is non-trivial. Perhaps Hologram has a command queue already for handling commands when disconnected, but that’s a key piece. When a command dispatch fails, it queues locally and retries with backoff. On reconnection, queued commands replay through the same server-side business logic (@jam’s proposal). Also simplifies the retry logic.


So, in summary, mapping back to @bartblast’s original questions:

1. Declarative sync

Configure an extension on the resource. Authorization rules determine what syncs. Actions and commands control write locality. Define the model once, declare where it lives, write normal Elixir.

2. Conflict resolution

Declared on the resource via the extension’s DSL (resolution_strategy :last_write_wins). Queued commands replay through business logic for natural conflict detection. Future versions can support per-field strategies and custom resolver functions.

3. Offline experience

Transparent. Actions work against the local store regardless of connectivity. Commands queue when unreachable and replay on reconnection.

4. Inspiration

The biggest inspiration is Ash’s data-layer agnosticism – same resource definition, multiple storage backends. @jam referenced Meteor’s “it just works” DX as the bar. I think the action/command model gets us there.

Really excited about this discussion!

6 Likes