LiveServerActions

Call Elixir functions from React, with optional type safety.

Inspired by Next.js server actions.

Features

Setup

Defining server actions

Use LiveServerActions inside your LiveView module and then define a function tagged with @server_action true:

defmodule MyAppWeb.FooLive do
  use Phoenix.LiveView
  use LiveServerActions

  ...

  @server_action true
  defp get_user_roles(_socket, %{ "user_uuid" => user_uuid }) do
    roles = Users.get_roles(user_uuid)
    %{ roles: roles }
  end
end

The first argument to a server action is always the LiveView's live socket. This argument is not present when the function is called from the client. On the client, serverActions.MyAppWeb.FooLive.get_user_roles is an async function called as follows:

import { serverActions } from "live_server_actions"

serverActions.MyAppWeb.FooLive.get_user_roles({ user_uuid: "abc-xyz" }).then(({ roles } => {
  ...
});

You can use type specs to export type information to Typescript:

@server_action true
@spec get_user_roles(Phoenix.LiveView.Socket.t(), %{ user_uuid: String.t() }) :: %{roles: [String.t()]}
defp get_user_roles(_socket, %{ user_uuid: user_uuid }) do
  roles = Users.get_roles(user_uuid)
  %{ roles: roles }
end

An equivalent type is now defined for serverActions.MyAppWeb.FooLive.get_user_roles (see next section).

Notice that the typed version of the function receives a map with the atom key :user_uuid rather than the string key "user_uuid". This is because of automatic string to atom munging.

LiveServerActions doesn't care about the type of the socket argument, so if you want to save some typing, you can replace Phoenix.LiveView.Socket.t() with any().

Server actions may be defined as either public or private functions. The choice is a question of style left to the user. It has no effect on the server action's functionality.

If you name function arguments then these names are exported to the corresponding Typescript type definition. For example, the following server actions all have a second argument named options:

@server_action true
defp my_server_action1(_socket, _options=%{protocol: protocol}) do
  ...
end

@server_action true
defp my_server_action2(_socket, %{protocol: protocol}=options) do
  ...
end

@server_action true
defp my_server_action2(_socket, options) do
  ...
end

Generated TypeScript .d.ts files

When a server action module MyApp.FooLive is compiled, a corresponding LiveServerActions__MyApp.FooLive.d.ts file is emitted in the assets/js folder. This file specifies the methods available for serverActions.MyApp.FooLive, and the type of each method if the corresponding Elixir function has a type spec.

Emission of .d.ts files only occurs when Mix.env() == :dev.

You can pass the d_ts_output_dir option to use LiveServerActions to change the output directory for .d.ts files:

use LiveServerActions, d_ts_output_dir: "/foo/bar"

# set a path relative to the project root dir
use LiveServerActions, d_ts_output_dir: fn root -> Path.join([root, "assets", "js"]) end

You can also customize the full path and filename of each individual .d.ts file based on the module name:

use LiveServerActions, get_d_ts_filename: fn output_dir, module_name ->
  Path.join([output_dir, "MyPrefix__#{module_name}.d.ts"])
end

The second parameter to the function is the module name as a string with the "Elixir." prefix removed.

If you want to disable generation of .d.ts files, you can set d_ts_output_dir to false.

Serialization

Values are serialized before being passed to server actions or returned to the client. At present, the following values are serializable:

In future, support may be added for customizing encoding/decoding of values. The restrictions on serialization of sets are imposed because JavaScript Set and Elixir MapSet have quite different identity semantics. For example, in JavaScript, new Set([new Set([1]), new Set([1])]) is a set with two members, whereas in Elixir, MapSet.new([MapSet.new([1]), MapSet.new([1])]) is a set with one member.

Updating the live socket

A typical server action will retrieve a value from the database and then return it. However, in some instances, you might want a server action to update the live socket (for example, to update socket.assigns). In this case, you can return a {socket, return_value} tuple from your server action. The tuple is automatically stripped before the return value is serialized and sent back to the client.

Form data

You can use live server actions and useActionState to set the action property of a <form>:

const [formState, formAction] = useActionState(
  (_currentState, formData) =>
    serverActions.MyAppWeb.FooLive.submit_form(formData)),
  {}
);

<form action={formAction}>
  <input type="text" name="foo" size="30" />

  {/* formState is updated when form is submitted */}
  <button type="submit">Submit</button>
</form>
@server_action true
defp submit_form(socket, form_data=%{}) do
  # this becomes the new value of formState above
  %{foo: "bar"}
end

Other notes on server actions

Embedding a React component in your LiveView

In your LiveView:

defmodule MyAppWeb.FooLive do
  use Phoenix.LiveView
  use LiveServerActions

  alias LiveServerActions.Components

  def render(assigns) do
    ~H"""
    <Components.react_component id="my-react-component-id" component="MyReactComponent" />
    """
  end
end

In your app.js:

import { addHooks, addComponentLoader } from "live_server_actions";
import { MyReactComponent } from "./my_react_component";

...
addHooks(Hooks);
...

// For React 19. Change as appropriate for your React version.
class ReactComponentLoader {
  constructor(component) {
    this.component = component;
  }

  load(rootElem, props) {
    this.root = createRoot(rootElem);
    Promise.resolve(this.component).then(c => {
      this.component = c;
      this.root.render(React.createElement(c, props))
    });
  }

  update(props) {
    this.root.render(React.createElement(this.component, props));
  }

  unload() {
    this.root.unmount();
  }
}

// The first argument to this function corresponds to the value
// of the &#39;component&#39; attr of the Components.react_component component.
addComponentLoader("MyReactComponent", new ReactComponentLoader(MyReactComponent));

// load this way if you want to load the component dynamically
//addComponentLoader("MyReactComponent", new ReactComponentLoader(import("./my_react_component").then(m => m.MyReactComponent)));

Examples

The examples dir contains two simple Phoenix apps using LiveServerActions. To demo the apps:

Example 1: a simple counter updated on the server

This example is a classic React counter demo, but with a counter that is stored on the server in an ETS table.

Clicking the button calls a server action which increments the counter and then returns the new counter value to the client.

See examples/counter in this repo and this readme.

Example 2: loading a random quote when a button is pressed

This example presents the user with a choice of fruits via a dropdown. When a button is pressed, a server action is called which returns an inspirational quote about the chosen fruit.

See examples/quotes in this repo and this readme.

Typing

The Typescript fallback type

If no Typescript equivalent is defined for an Elixir type, or if no @spec was defined for a server action, then any is used by default as a fallback type for arguments and return values. You can change this default to unknown:

use LiveServerActions, typescript_fallback_type: :unknown

You can also override this default on a per-server-action basis:

@server_action [typescript_fallback_type: :unknown]
@spec my_server_action(...) :: ...
def my_server_action(...) do
  ...
end

Automatic string to atom munging

Elixir's type spec syntax does not allow the specification of maps with particular string keys. To work around this limitation, maps with string keys are automatically converted to maps with atom keys if the server action is given a suitable type spec.

To illustrate, consider the following server action. When get_email_address is called from JavaScript, it will be passed a JavaScript object of the form {user_uuid: "xyz-abc"}. This then translates to the Elixir map %{ "user_uuid" => "xyz-abc" }. However, because the type spec defines the second argument of the function as a map with the key user_uuid, this map is automatically converted to %{ user_uuid: "xyx-abc" } before being passed to the function.

@server_action true
@spec get_email_address(
  Phoenix.LiveView.Socket.t(),
  %{user_uuid: String.t()}
) :: %{error: String.t()} | %{email: String.t() }
defp get_email_address(_socket, %{user_uuid: user_uuid}) do
  ...
end

If you want to avoid auto-munging, use the type map() instead of specifying specific keys (or just don't add a type spec at all). For example, the following server action receives %{ "user_uuid" => "xyz-abc" }:

@server_action true
@spec get_email_address(
  Phoenix.LiveView.Socket.t(),
  map()
) :: %{error: String.t()} | %{email: String.t() }
defp get_email_address(_socket, %{"user_uuid" => user_uuid}) do
  ...
end

The choice of string keys or atom keys does not matter for the return value of a server function, as both %{foo: "bar"} and %{ "foo" => "bar" } are converted to the JavaScript object {foo: "bar"}.