Client-side rendering, server-side state (StateChannel)

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:

  1. front-end React code: shopper_umbrella/main.jsx at main · hubertlepicki/shopper_umbrella · GitHub
  2. 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.

11 Likes

You may already be familiar with this, but here is a related approach with similar goals - How it works - Inertia.js

I’m a little busy right now, but as this is very exiting a few quick thoughts.

I’m thinking about the exact same thing some time now. Then I found Storex, see Storex - Frontend store with the state on the backend - #8 by Sebb and played a little with that. Do you know it?

I have some demo code where I rebuilt the solidjs-store example

in Elixir.

The only problem right now is, that the Storex store does not play nicely with solid’s reconcile and from - one has to clone all data coming in. But its good enough to see that this is a great approach.

Very much looking forward to what becomes of this, and eager to help, will look at your demo asap, ie next year :wink:

interesting, but a GenServer-based backend (so basically a LV without heex but reactive rending from a Elixir controlled client-store) will surely have the upper hand here in theory.

Wow, I did not know of Storex, and @kanishka , I didn’t know about interia.js. Thank you both!

Storex (//cc @drozdzynski ) looks super similar, especially in usage, but I would be super interested to hear why @drozdzynski you chose not to use JSON diff/patch and implemented your own solution.

OK so I did some comparisons with Storex and deployed it here, you can compare the same app / UI:

StateChannel + React:
https://react-phoenix-server-state.fly.dev/

Storex + React:
https://react-phoenix-server-state.fly.dev/storex_test

The code I translated to Storex is here:

and

When it comes to usage, performance and such, they’re both very comparable. StateChannel uses less text over the wire, as you can see below, but not sure how significant is that.


In general, I wish I knew about Storex before I started this thing, but also it’s great to see the code converged to very similar solution for the problem, independently, twice.

3 Likes

I’m thinking this approach would work really well with server-side rendered JS apps too, if I add support for exposing the initial state of channels through API. Then, this API could be called from the server-side part of the app to fetch the initial state and render the page server-side. With Next.js, this would be done using getServerSideProps: Data Fetching: getServerSideProps | Next.js

Then, we could do either what LiveView does, and initialize the state again when the Websocket connects or do a slight optimization and hold the state in memory for some time until client via websocket connects and then hand over the state. I suspect the first approach (like LiveView double render) is both easier and safer.

1 Like

Now I have some time to look into this, some quick thoughts:

As I said before, I completely agree with your premise. We need sth like this.

There is even more prior art, see

So obviously there is something going on. Forces should be joined.

Good point. Nice and straight forward impl with set/patch_state. This should make the backend fast enough (and faster than storex).

As for the client-side impl, can you say how busy React is applying the new state? For solidjs there should be a way to directly translate a JSONpatch to setStore’s path API (so there is close to no work to do on the client).

SSR is interesting, but I have no clue and no comment.

What do you think about using Phoenix routing with StateChannel? Or generally speaking: what is needed to do with StateChannel and Phoenix what https://inertiajs.com/ does.

What do you think of implementing the GitHub - krausest/js-framework-benchmark: A comparison of the performance of a few popular javascript frameworks app instead of your shopper. Will give some more insight in the performance of the approach.

related:

1 Like

I’m the author of tha live_data where I explored this idea, thanks for the mention @Sebb.

I still think it’s an interesting direction. The most problematic thing in this approach (true for both LiveData and LiveView) is that it relies on network to apply local state/UI changes. For some cases it’s not a big deal, for example if we are building a big interactive dashboard when most of the changes are going on on the server. For building apps in general, I think the ability to apply mutations locally is a must, that’s why I was never a big fan of LiveView.

Since then, I’ve discovered Replicache and watched its development closely. It builds on the idea of holding the state on the server as a single source of truth and pushing diffs to clients, but it also allows clients to apply mutations optimistically with casual consistency which results in a much better user experience.

While Replicache is (as of now) designed mostly around JS ecosystem, it’s in its core backend agnostic. I think exploring how well it will play together with the Elixir Way is worth looking into, I have a feeling it’s a great fit.

4 Likes

Warning: I have very limited web-dev experience, just some internal tools in LV and Django, so most likely I’m missing stuff. (I’m looking into this, because I see the pain we have with an Angular App with REST-Backend and how easy that would be in LV)

As I see it we have 3 kinds of state:

  • local state, never interesting for the server, never stored in the backend. This can be easily handled by a separate store on the client. (example: the position of an element while moving it)
  • computed state, always needs the server. (that’s the core use case of StateChannel et al.)
  • server checked and/or stored state. The state can be computed by the client, but may be invalid, optimistic render would be nice. This could be done with the same approach Hubert uses on the server: optimistically update the local state and send the patch to the server. Client will directly render but maybe the server will overwrite the change. (example: the position of an element after movement has finished. We may want to store this in the backend and it may be invalid, so the old position has to be restored).

Things get complicated if you need CRDT, which is only true for collaborative apps. Replicache seems nice for that as long as you are OK with paying for a blackbox.

1 Like

I think in real life, the lines often get blurred between these types of state, but my view is that majority of state actually should live on the client, and the client (as in web browser) should also do it’s share of rendering HTML and processing the data/state. Not only this saves us some CPU cycles on the server, that we have to pay for, but the user gets the ultimate “edge computing experience” where the computations needed are done on their own device.

Having said that, I think in the ideal web app, the lifecycle starts with client requesting a page, and, it makes sense to deliver that already rendered into HTML. Then, ideally, the client takes over and makes the page interactive, maybe whole page maybe just bits of it here and there. Then, when user navigates to another page, or significantly changes the data that is displayed on the page (as in going to "Next’ page of paginated results), another request to the backend is made and the page the user sees should morph into another page, the new elements should be made interactive, and ideally - some of the elements on the page that didn’t change shouldn’t be updated at all. I did some digging over Christmas and the framework I found for achieving the above exists, and it’s also within React ecosystem - Next.js 13 with React 18 and Server Components can achieve (and indeed does according to my tests) just that.

As mentioned in the original post, the main pain point in developing web apps is creating APIs, and consuming APIs, and this can be avoided with Next.js by fetching most or all of the data in one go, when the page loads, similar to the way we do it in Phoenix controllers.

In fact, I played some more with Next.js and was able to hack together a simple solution where Next.js is treated as a “view layer” for Phoenix: https://twitter.com/hubertlepicki/status/1607705791460528128

Then, the majority of user interactions on the majority of web apps are clicking around, going to different pages, listing stuff, etc.

Where StateChannel/LiveData/Storex comes to play, I think, is data mutations, especially with server-side validations. If there’s a form to add a record, edit it, maybe even collaboratively, or some sort of data dashboard as well that updates in real-time, we open a WebSocket connection to the backend, and we send messages to it and receive state updates that are synced in somewhat real time to the client, that updates in place. There’s also a possibility to do limited data loading this way, but if we were to base the whole application around it, we’d end up with a very similar set of issues you end up with when using LiveView: in particular reconnection problems, problems on slow/unreliable networks, no possibility to do anything offline, issues with handling big state as in very robust, fast-changing and interactive pages that need to sync a lot of data back and forth, etc.

I should probably put together some more advanced demo of this, I was looking for a more advanced “Todo MVC” app template to replicate and compare the approaches with, and I found Live Beats by @chrismccord https://livebeats.fly.dev/ which is very impressive. I plan to replicate the same thing with Next.js + StateChannel (i.e. just Phoenix Channels) and see which one I like more. This is all just experimenting for now.

1 Like

I’d like to have only view-related logic and state in the client. I do not want to bring parts of the business knowledge and logic to the client. But out of the perspective of the library developer that does not matter - all the use cases have to be implemented anyways (if you would not need an API we wouldn’t be here).

You seem to have a completely different app in mind than me. (More of a classical web-page). I’m am very biased because I have the refactor of a specific app in mind (its a graphical planning tool for an electrical installation that lets you put devices and their logical connections on a floorplan. Close to everything has to be put in a database and there are many business decisions to be made, so : server-heavy).

I do not get the point of the next-js demo. Why is it better than having a reactive store?

I understood the premise of your original post like: you want to get a developer experience like LV for apps where you need so much JS that LV becomes awkward. Did I understand that wrong? (At least that is what I am looking for.) To get the LV-DX an important part for me is not to have to learn thousands of JS-tools and frameworks. I’m OK with replacing heex with a JS-UI-lib like react or solid, but thats it.

Also: all the issues you list here are either

  • not avoidable if you rely on an API
  • are in the StateChannel-approach not as bad as in LV, as you can keep as much of the state on the client as you like - LV cant do that. Reconnects I imagine can also be handled better.

right and very interesting experiments that is. Looking forward to a more involved next-js example.
I’ll try to write a more involved example with StateChannel and Solid.

I agree. But there’s a lot of UI state to handle, like what is focused, what is entered into field, what is collapsed and what is expanded, where’s the scroll… I don’t want to handle that on the server.

Sure! For me that’s one of the main points of this thing. That’s when LV gets awkward. (Though there is a lot you can do with just LV and the App has to be really dynamic imo before ditching LV is the better option).

and another one: GitHub - justinmcp/taper: Taper is a React (with SSR) and server-side-redux-like environment for Elixir+Phoenix. by @justinmcp

1 Like

also see LiveState:

2 Likes

Good talk, but LiveState (for now) has the focus on webcomponents.

Agree on this slide:

It isn’t javascript that makes client side development terrible
It’s the complexity of managing requests, responses, and distributed state

@superchris in the mean time, did you experiment with live-state for a complete (React)app? (which is the point of this thread).

EDIT: just found https://github.com/launchscout/live_state/tree/main/use-live-state (react hook).

1 Like

Nice finds, taper seems abandoned, however. LiveState seems like another very close idea to mine! In fact it looks very similar, and the author even uses Json Diff / Patch approach!

2 Likes

Yes, but has not the nice set/patch_state option that can speed up things quite a bit.
Also I can’t get the testbed running, seems like sth is missing. didn’t dig too much though.

I’m glad you found LiveState! I’m the author, and I’m considering it ready to use with feedback from more projects being one of the things we need most. We are using it in a couple places on our own website already. I would welcome feedback and collaboration.

While it is true I focused on web components, the React hook was very easy to write (other than fighting with npm packaging issues, grr!). The design of the js library is to have a lower level API designed to facilitate integration with frameworks, and higher level APIs like the react hook and custom element decorator to make it super easy for users to build applications.

4 Likes