I have been thinking a lot about what are the pain points in web development, and what the remedy can be. First off, I’ve been programming web applications since about Rails 1.2, when the REST concept was introduced and was considered revolutionary, and I went through different stacks, that were both server-side, and client-side rendered: Dojo, Ember.js, React, Stimulus, LiveView, and many others…
Recently I was taking part in several projects using LiveView and one using Surface UI. I have got to say that both are great and definitely step in the right direction because they solve the problem of the biggest pain point in web development: creating and consuming the APIs. They solve it by cutting out the middleman completely, allowing you to fetch the data in Elixir, then, in a place where you’d have to build API and API client - do absolutely nothing! - and then render the template. Boom, instant productivity boost.
But LiveView is also not without it’s trade-offs, and a major one is the fact of being… Elixir library, and not JavaScript library, so the integration with JavaScript ecosystem (via Hooks or otherwise) is not always as easy and straight-forward as it’d be if I just rendered the templates in React. There are other issues, obviously, that I won’t mention here, but everyone who worked with LiveView on a fancy, JavaScript-heavy app knows this one so it’s what I mention.
Now, what if we reversed the situation? What if we kept React (or your framework of choice) and render the application client-side, in JavaScript, but we also kept the beloved feature of LiveView and not have to write or consume any API?
My idea is to put the state in the PhoenixChannel, and sync it with the client on the JavaScript side, whenever it changes.
The idea is that the client-side code emits events, that are being sent to Phoenix Channel as messages. Phoenix Channels library already is taking care of making sure the events are arriving and being processed in order in which they were emitted. Once they reach Phoenix Channel, they get consumed by programmer’s code and state is being updated, and synced back to client-side JavaScript app. The client-side app (say in React) reacts to state being updated and re-renders the parts of the application that changed.
I was experimenting in how to do that, and came across JSON diff and patch, as described in RFC 6902 - more info here: https://jsonpatch.com/.
There are libraries for both JavaScript and Elixir that can compute diff of arbitrary JSON structures, and apply patches. Patches are quite small and concise, and understand appending, inserting at position, replacing, and deleting parts of JSON/nested maps. It’s also relatively cheap to compute a diff, and apply a patch.
So, my thinking is that, the Elixir programmer would initialize state in the Phoenix Channel, to a map, map of maps and / or other basic types, and either update the map directly - resulting in the library that I created to wrap Phoenix Channels to calculate the diff - or - in case of large state and the need for speed - the Elixir programmer can produce JSON Patch operations themselves, and send these over the wire directly to the client-side app and apply patches locally on the server-side, so both states stay in sync.
The relevant Elixir code that does the above, and wraps around Phoenix Channels can be found in this file state_channel/state_channel.ex at main · amberbit/state_channel · GitHub
On the JavaScript side, I chose React, but it’s possible to create bindings for pretty much anything. Anyway, on the React side, we have a library that delivers Providers for Channel and State, allowing for the React components to just consume the state, without worrying about it’s synchronization, and allowing for events to be sent to the backend. The relevant file with JavaScript code is here: state_channel_react/index.jsx at main · amberbit/state_channel_react · GitHub
Now, how would a programmer use it? Let’s start with a React application. At first, the programmer needs to wrap his or her components into a provider, that connects to the specified channel and listens to the state changes, like this:
<PhoenixSocketProvider>
<StateChannelProvider topic="app:state">
<MyComponent />
</StateChannelProvider>
</PhoenixSocketProvider>
Within the component, you can consume the state, pass it whole or as props to child components, and emit events:
const MyComponent = () => {
const {state, pushMessage} = useStateChannel();
return(
<div>
<h4>Current state is:</h4>
<pre>
{ JSON.stringify(state, null, 2) }
</pre>
<a href="#" onClick={(e) => {e.preventDefault(); pushMessage("change_state")}>Change state!</a>
</div>
)
};
The attached click handler sends message “change_state” to the server.
Now, in our channel, responsible for handling “app:state” topic, we can react to the state being changed, by setting a new state. The state diff will be then computed, and JSON diff structs will be transfered over the wire back to the client, and it will re-render using the new values.
defmodule MyApp.AppChannel do
use StateChannel
@impl Phoenix.Channel
def join("app:state", _message, socket) do
{:ok, socket |> assign(:state, init_state())}
end
@impl Phoenix.Channel
def join(__otherwise, _params, _socket) do
{:error, %{reason: "unauthorized"}}
end
@impl StateChannel
defp init_state() do
%{
"greeting" => "Hello, World!"
}
end
@impl StateChannel
def on_message("change_state", value, socket) do
socket
|> set_state(%{"greeting" => "Hello, Elixir!"})
end
end
Again, this works with small state well, for large state you can use patch_state
with JSON path that will update the server side state in place, and send the diff - that doesn’t have to be computed - to the client:
@impl StateChannel
def on_message("change_state", value, socket) do
socket
|> patch_state("/greeting", "Hello, Elixir!"})
end
Since state updates happen exclusively on the server, in Channels, this approach can be used to build collaborative applications similar to LiveView, or listen to some server-side events and update state, or even components on the same page can communicate using OTP messages or PubSub to interact behind the scenes - and when state of either of the components gets updated on the server - it gets synced to clients.
I have put together a little bigger demo and deployed to fly.io:
https://react-phoenix-server-state.fly.dev/
The source for the app is here:
GitHub - hubertlepicki/shopper_umbrella: An example application to demonstrate StateChannel + React and releavnt files are:
- front-end React code: shopper_umbrella/main.jsx at main · hubertlepicki/shopper_umbrella · GitHub
- back-end Channel code:
shopper_umbrella/app_channel.ex at main · hubertlepicki/shopper_umbrella · GitHub
The sources for React and Pheonix libraries used by the app above are here:
The code itself is a proof-of-concept quality, but, for the task of the demo app - it seems to work just fine.
What do you folks think of this approach? I am sure there is a thousand concerns, and things I didn’t consider, and I would appreciate suggestions and criticism.