In the beginning, data was transferred from a web page to a web server via a form submission. Any information coming from the server needed to arrive along with a new page, in the form of a browser redirect. This was a simpler time, when stateful browser applications were not yet a thing.
Over time, people started to see the need for richer applications. Experiences such as fast page navigations, lazy-loading data, detailed loading states, were not allowed by a fully server rendered approach. This lead to the rise of "single-page apps", and with the SPA came the front end frameworks, such as AngularJS, Ember, and React.
This new category of tools ushered in a new age of products, where the expectation was that the product function more like an app on a phone than a web page. Full page loads became frowned upon by users, and everyones experience on the web improved as a result.
But at what cost?
While the way that users interact with applications became easier, the applications themselves have became more difficult to create... To enable better experiences, projects need to take on several new classes of issues:
- Asynchronous server calls
- Internal state management
- Business logic duplication
Many developers that have built reasonable complex front end applications will recognize the above issues. Good experiences require eliminating pages loads, which means API calls must be used, and therefore many requests will need to have their state tracked (to indicate loading, for example). Data loaded from the server must be modeled on the front end, so that it can be read and updated efficiently. This often brings state management tools in that inevitably make a project more difficult to reason about. Showing early form-feedback requires duplicating business logic on the client side, which may not match the logic encoded on the backend.
At this point you may have a pretty good picture of the problems I'm trying to portray. It's possible that you have grappled with them in the past, and attempted to find solutions for some.
I think that a great development team will be able to handle these problems without much difficulty, just by creating the right abstractions and conventions while programming the application at hand. However, my point in writing this is that I do not think a great development team should be required. Really, our tools should be responsible for solving theses problems.
Exploring the Render Spectrum
To create a visual representation of the behavior of a tool when it comes to rendering, I use a line that stands as a spectrum. In its most basic form, the line looks like this:

On the far left is "React", which fully commits to rendering applications on the client (yes, even though SSR allows pre-rendering, that is ultimately not where the application logic resides, so I don't count it).
On the far right is "Phoenix", Elixir's web framework. Phoenix is a full commitment to rendering applications from the server. Interactivity can be added with JavaScript snippets embedded in the page, but those are aesthetic and state changes are done via full page loads for the most part.
As with any spectrum, there's more to it then just the points on the far ends, it's the in-between parts that we are going to explore here.
Phoenix LiveView: Towards server-side-interactivity
Recently, a new tool emerged in the Elixir community called LiveView. With LiveView, applications are rendered from Phoenix on the server, with a key difference: web pages are sent with a JavaScript library that allows live updates to be received from the server and rendered inline, no full page load required.
This is incredibly cool, I highly recommend checking out one of the many demo videos out there and giving it a try yourself. With LiveView, developers can develop features that live in a single context (the server). No business logic needs to be duplicated on the front end, there is no state to manage, and the library handles all client-server communication. Our three issues are solved, and our spectrum now looks like this:

(LiveView leans more towards the "server" than the "client", and this is reflected on the spectrum.)
However, LiveView is not a perfect solution in my mind. First of all, it commits the development team to using Elixir/Phoenix almost completely. Whereas before, a front end team could use their tool of choice (React or otherwise) and the backend team could choose theirs, there is now a coupling of tools to gain the benefits of LiveView.
It's up to the team to decide if this tradeoff is worth it, and I suspect it is worth it in many cases, especially if the team is small and needs to be highly productive, and is able to adapt to new tools quickly.
Another downside of LiveView is that all interactivity must go through the server. This can be slow, but my larger concern is that interactions that should remain entirely client-side will be made a server concern. For example, whether or not a particular toggle is open. Aesthetics such as those still need to be done with JavaScript inserted into the page.
Live...Embed? A potential hybrid solution
So maybe the front end team does not want to give up their precious JSX for EEX templating. Or certain features of the application really do need to be rendered in the client. Is there any compromise?
A solution in this case could be to insert iframes into a normal React application, and then rendering LiveView templates inside those iframes. If we put this on our render spectrum, it lands right in the middle:

This approach may work well in the case that there is an existing legacy application, and the team wants to experiment with LiveView without committing to a full rewrite (smart move, team).
On top of that, small UI-only concerns, such as toggles, can be handled entirely in React. Unless, of course, it needs to be rendered as part of the feature being handled in the LiveView iframe.
This might be my personal least favorite option. One reason being that it could create a maintenance Hell by sacrificing convention, developers may be confused as to whether a feature is/should be in React or a LiveView template. In addition, communication between LiveView and React, while possible with window message passing, is cumbersome and potentially adverse for security.
LiveState: The best approach of all?
I think there's a better way. While playing around with LiveState and React, I realized that I want to be able to use all of the great tools I know and love in React, but want the ease of rendering on the server side.
What I came up with I call LiveState, which functions similarly to LiveView, except instead of sending HTML updates to be rendered inline in the browser, it sends state updates as JSON to be rendered as part of a React app. It sits in the final spot on our render spectrum:

With LiveState, we keep every advantage of working with React since, at the end of the day, it's still just a React app. On the other hand, by connecting to a LiveState socket, we get everything offered by LiveView.
No client-server communication needs to be handwritten, and loading states can be tracked by the library. Business logic can all live on the server, the front end application only needs to know how to render the state, and which actions to send. Lastly, there is no complex internal state to worry about. Aside from small UI-specific state, the state is held in a Phoenix channel.
That all sounds good, but what does an application using LiveState actually look like? In the rest of this post, I'll take you on a tour of the concepts behind LiveState, to give you a better idea of all the cool things it can do.
LiveState in Action
To demonstrate what the different aspects of LiveState are, and how they interact with each other, I will use a "checkout" feature as an example. This feature will require the user to go through three steps: enter details, enter payment info, and confirm. Each step will accept input from the user, which is submitted and validated between each step.
The Client
In our React application, the top-level checkout component may look like:
function Checkout () {
const { step } = useLiveState()
const nextStepEvent = useLiveEvent('next_step')
function handleNext (params) {
nextStepEvent.push(params)
}
return (
<CheckoutWrapper loading={nextStepEvent.pending}>
{step === 0 && <EnterDetailsStep onSubmit={handleNext} />}
{step === 1 && <EnterPaymentInfoStep onSubmit={handleNext} />}
{step === 2 && <ConfirmStep onSubmit={handleNext} />}
</CheckoutWrapper>
)
}
This is a simplistic example, and certain parts are omitted. The first important concept to notice is the useLiveState
hook. This attaches to the LiveState context that the component lives in, and exposes the server state that determines what is rendered. Child components of the Checkout
component can also use this hook, so that state can be accessed without having to pass props through many layers.
The second important concept is the useLiveEvent
hook. This gives us a way to push events to our LiveState controller, running on the server. Here, the event is called by a child component when a step is completed, passing some params to the server, which (if successful) causes step
to increment. Notice that this event also handles the loading state for us; if an event push is pending, we can access that state on the value returned to us by the hook.
There could be separate events for each onSubmit
callback, or each child component could define their own useLiveEvent
events, and this example would still function. However, the current view is best to illustrate the key concepts.
The Server
Meanwhile, the actual logic is handled on the server:
defmodule MyApp.LiveCheckout do
use LiveState.Channel
def join("checkout", _payload, socket) do
socket = assign(socket, :state, state_new())
{:ok, send_state(socket), socket}
end
def state_new() do
%{step: 0, ...}
end
# Handle submit for "Enter Details" step
def handle_in("next_step", %{step: "enter_details"} = params, socket) do
case CheckoutService.enter_details(params) do
:ok ->
socket |> next_step() |> reply()
{:error, reason} ->
socket |> put_error(reason) |> reply()
end
end
# Handle submit for other steps...
...
# Increment socket.state.step
def next_step(socket), do: ...
# Set socket.state.error to the message
def put_error(socket, reason), do: ...
end
The LiveState.Channel
is a thin layer on top on Phoenix channels, as this solution is not as built out as Phoenix LiveView yet. However, hopefully the intention is clear from the above code snippet.
When the channel is joined, a new state object is created, which becomes the initial state for the client side. This is serialized to JSON and exposed by a useLiveState
hook in React.
When an event is received, it gets pattern-matched by one of the handle_in
module functions. Within the handler, some operation is performed based on the event type and params provided. The socket state can be modified at this point, and a reply sent back to the client. In this case, if the submission is ok, then the state is moved to the following step.
Conclusion
Building amazing user interfaces can be hard, it's amazing how much complexity is often justified in the name of making a product externally simple. After reviewing several options on the render spectrum, it's clear that LiveState may offer a long-awaited solution to this exploding complexity.
LiveState builds on the advantages of fully-rendered client apps plus the recent innovations made by the Phoenix team with LiveView. It provides a seamless layer of communication between client and server, even managing intermittent request states for the programmer. It keeps business logic in one place (on the server) to reduce errors brought on by duplication. And, maybe best of all, it eliminates the need for any complex state management on the front end.
To summarize, LiveState has the potential to remove a lot of the complexity from the front end, and allow front end developers to focus on building incredible user interfaces. Meanwhile, it empowers developers on the backend to build functioning and well-architected features without a high degree of collaboration needed from the front end team.
To get updates on LiveState, such as when it's released, follow me on Twitter.