LiveState

repository·main·Indexed 43 days ago

https://github.com/launchscout/live_state

An Elixir library for building interactive web applications by synchronizing state between a server and client via Phoenix Channels. It uses a single source of truth model where clients dispatch events and receive state updates as JSON patches to minimize payload size. LiveState decouples server logic from client-side rendering, making it suitable for non-Phoenix environments and custom frontend implementations using libraries like Lit, React, or vanilla JavaScript.

Tokens
4.7K
Snippets
15
Records
19
Agent score
55%

What's inside LiveState

  1. How LiveState works

    main

    LiveState is a state-synchronization framework where the server acts as the single source of truth.

    The Workflow:

    1. Events: Clients dispatch events to the server.
    2. Handling: The server handles these events in Elixir and updates the application state.
    3. Updates: The server pushes state updates back to the client. Subsequent updates are diffed using the jsondiff library and sent in jsonpatch format to minimize payload size.

    Comparison with LiveView: While LiveView manages both server logic and view presentation in Elixir, LiveState separates them. LiveState event handlers are written in Elixir, but the client is responsible for rendering the state and dispatching events. This makes LiveState better suited for applications where the client-side rendering logic needs to be decoupled from the server or used in non-Phoenix environments.

  2. State encoding and serialization

    main

    LiveState encodes state using the c:LiveState.Encoder protocol before serializing it to JSON.

    • Default Behavior: The default implementation handles standard Elixir terms, converts structs to maps, and strips the __meta__ field from Ecto.Schema structs.
    • Customization: You can implement the c:LiveState.Encoder protocol for custom encoding logic.
    • Message Building: For full control over the encoding and serialization process (including how diffs are generated), you can provide a custom module via the message_builder option in use LiveState.Channel.
  3. Install LiveState in a Phoenix application

    main

    To use LiveState, add it as a dependency in your mix.exs file. Since LiveState is often used for embedded apps that need to be accessed from different origins, you should also add CORSPlug to your Phoenix endpoint to handle Cross-Origin Resource Sharing.

    1. Add :live_state and :cors_plug to your deps.
    2. Add plug CORSPlug to your Endpoint module.
    # mix.exs
    def deps do
      [
        {:live_state, "~> 0.7"},
        {:cors_plug, ">= 0.0.0"}
      ]
    end
    
    # lib/pipe_spot_web/endpoint.ex
    defmodule PipeSpotWeb.Endpoint do
      use Phoenix.Endpoint, otp_app: :pipe_spot
    
      plug CORSPlug
      # ...
    end
  4. Set up a LiveState Channel

    main

    To use LiveState, you must integrate it with Phoenix Channels following these three steps:

    1. Configure the Endpoint: Register a socket in your Endpoint module.
    2. Create the Socket Module: Define a module using Phoenix.Socket to handle connections and topics.
    3. Implement the Channel: Use the LiveState.Channel behaviour in your channel module, passing your web module via the web_module option.
    # 1. Endpoint setup
    defmodule MyAppWeb.Endpoint do
      socket "/socket", MyAppWeb.Channels.LiveStateSocket
    end
    
    # 2. Socket module
    defmodule MyAppWeb.Socket do
      use Phoenix.Socket
    
      channel "topic", MyAppWeb.Channel
      @impl true
      def connect(_params, socket), do: {:ok, socket}
    
      @impl true
      def id(_), do: "random_id"
    end
    
    # 3. Channel implementation
    defmodule MyAppWeb.Channel do
      use LiveState.Channel, web_module: MyAppWeb
    end
  5. Install LiveState

    main

    Add live_state to your mix.exs dependencies. It is highly recommended to also include cors_plug to manage cross-origin requests, ensuring clients can connect to your channels securely.

    def deps do
      [
        {:live_state, "~> 0.8.1"},
        {:cors_plug, "~> 3.0"}
      ]
    end
  6. Configure esbuild for custom elements

    main

    When building custom elements, you must configure esbuild to target es2020 and create a separate build target for your custom elements bundle. This ensures the generated JavaScript is compatible with modern browsers and includes your component logic.

    In config/config.exs, add a custom_elements target to your esbuild configuration and add a corresponding watcher in your dev configuration.

    # config/config.exs
    config :esbuild,
      version: "0.17.11",
      pipe_spot: [
        args: ~w(js/app.js --bundle --target=es2020 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
        cd: Path.expand("../assets", __DIR__),
        env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
      ],
      custom_elements: [
        args: ~w(js/custom_elements.js --bundle --target=es2020 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
        cd: Path.expand("../assets", __DIR__),
        env: %{"NODE_PATH" => Path.expand("../deps", __DIR")}
      ]
    
    # dev config
    config :pipe_spot, PipeSpotWeb.Endpoint,
      watchers: [
        esbuild: {Esbuild, :install_and_run, [:pipe_spot, ~w(--sourcemap=inline --watch)]},
        esbuild_custom_elements: {Esbuild, :install_and_run, [:custom_elements, ~w(--sourcemap=inline --watch)]}
      ]
  7. Implement LiveState.Encoder for custom diffing behavior

    main

    The LiveState.MessageBuilder relies on the LiveState.Encoder protocol to transform Elixir terms into a JSON-compatible format before calculating diffs.

    If you find that JSONDiff is producing incorrect patches for your custom data structures (e.g., complex nested maps or specialized structs), implement the LiveState.Encoder protocol for those types. This gives you control over the 'pre-diff' representation that the builder uses to calculate the state:patch messages.

  8. Embed a LiveState custom element in HTML

    main

    To use a LiveState custom element on any website, include the compiled JavaScript bundle and use the custom element tag with a url attribute pointing to your LiveState WebSocket endpoint.

    Example usage:

    <html
    <head>
      <!-- Path to your custom elements bundle -->
      <script type="module" src="http://localhost:4000/assets/custom_elements.js"></script>
    </head>
    <body>
      <!-- The url must point to your Phoenix LiveState socket endpoint -->
      <contact-form url="ws://localhost:4000/live_state"></contact-form>
    </body>
    </html>
    <html
    <head>
      <script type="module" src="http://localhost:4000/assets/custom_elements.js"></script>
    </head>
    <body>
      <contact-form url="ws://localhost:4000/live_state"></contact-form>
    </body>
    </html>
  9. Implement LiveState.Channel callbacks

    main

    To manage state and handle events in a LiveState channel, implement the init/3 and handle_event/4 callbacks from the LiveState.Channel behaviour.

    • init(_channel, _params, _socket): Returns {:ok, initial_state_map}.
    • handle_event(event_name, payload, state): Processes incoming events and returns {:noreply, new_state}.
    @impl true
    def init(_channel, _params, _socket) do
      {:ok, %{complete: false}}
    end
    
    @impl true
    def handle_event("create-contact", contact_attrs, state) do
      case Contacts.create_contact(contact_attrs) do
        {:ok, _contact} -> {:noreply, Map.put(state, :complete, true)}
        {:error, _} -> {:noreply, state}
      end
    end
  10. Implement LiveState properties and events in TypeScript

    main

    To connect a custom element to LiveState, use the @liveState decorator and the @liveStateProperty() decorator.

    • Use @state() (from Lit) to ensure the property triggers re-renders.
    • Use @liveStateProperty() to tell LiveState to manage the property.
    • In the @liveState decorator, define the topic and the events.send array to specify which CustomEvents the element will dispatch to the server.

    Example implementation using Lit:

    import { LitElement, html } from 'lit';
    import { customElement, state } from 'lit/decorators.js';
    import { liveState, liveStateProperty } from 'live_state'; // conceptual import
    
    @customElement('contact-form')
    @liveState({
      events: {
        send: ['create-contact']
      },
      topic: 'contact_form:all'
    })
    export class ContactForm extends LitElement {
      @state()
      @liveStateProperty()
      complete: Boolean = false;
    
      render() {
        if (this.complete) {
          return html`<div>Thank you!</div>`;
        }
        return html`
          <form @submit=${this.submitForm}>
            <input name="name" required />
            <button>Save</button>
          </form>
        `;
      }
    
      submitForm(e: SubmitEvent) { 
        e.preventDefault();
        const formData = new FormData(e.target as HTMLFormElement);
        const data = Object.fromEntries(formData.entries());
        this.dispatchEvent(new CustomEvent('create-contact', { detail: data }));
      }
    }
  11. Handle client events with handle_event/3 and handle_event/4

    main

    Implement c:LiveState.Channel.handle_event/3 to process events dispatched from the client.

    Arguments:

    • event_name: The name of the event.
    • payload: The data sent by the client.
    • current_state: The current application state.

    Return Values:

    • Return {:noreply, new_state} to simply update the state.
    • Return {:reply, events, new_state} to update the state AND dispatch one or more Event structs to the client.

    If you need access to the socket, implement c:LiveState.Channel.handle_event/4 instead.

    # Simple state update
    def handle_event("add_todo", todo, %{todos: todos}) do
      {:noreply, %{todos: [todo | todos]}}
    end
    
    # Update state and dispatch client-side events
    def handle_event("add_todo_with_one_reply", todo, %{todos: todos}) do
      {:reply, %Event{name: "reply_event", detail: %{foo: "bar"}}, %{todos: [todo | todos]}}
    end
    
    # Update state and dispatch multiple client-side events
    def handle_event("add_todo_with_two_replies", todo, %{todos: todos}) do
      {:reply,
       [
         %Event{name: "reply_event1", detail: %{foo: "bar"}},
         %Event{name: "reply_event2", detail: %{bing: "baz"}}
       ],
       %{todos: [todo | todos]}
      }
    end
  12. Client-side LiveState libraries

    main

    To consume LiveState on the frontend, use one of the following related projects:

    • phx-live-state (npm): The core frontend library for sending/receiving events and subscribing to state changes. Supports building Custom Elements.
    • live-templates (npm): A lightweight way to connect frontend templates to a LiveState channel without writing custom JavaScript or Custom Elements.
    • use-live-state (React): A dedicated React hook for integrating LiveState into React applications.