Recently, I had a sudden urge to build a web chat. It was a very quick adventure that nevertheless presented a few interesting lessons that I’d love to share.
I wanted to get to the meat of sending messages around as fast as I could, so decided to skip the complexity of session management in the beginning. Yet, people still should be able to identify themselves somehow.
The simplest decision was to use JS prompt
on page load to ask for a nickname.
<!-- show.html.heex for /rooms/:room -->
<div id="show-page" phx-hook="PromptForNickname" >
Now we can host logic prompting to enter a nickname on the frontend, so that it will be executed at page load.
export const PromptForNickname = {
mounted() {
this.pushEvent("set_nickname", { nickname: prompt("Your nickname") });
},
};
Then we can add a simple event handler and that’s all it takes to pass data between browser and server:
@impl true
def handle_event("set_nickname", %{"nickname" => nickname}, socket) do
{:noreply assign(socket, :nickname, nickname)}
end
This works. Well done!
But this leaves a lot of room for improvement, and the first one is to ensure that nickname is at least not empty. That type of validation, input validation, is best performed on the edge. Which is frontend code in our case. Let’s introduce a simple do/while
loop that will keep showing prompt until user entered something.
mounted() {
let nickname;
do {
nickname = prompt("Your nickname");
} while (nickname == null || nickname.length == 0);
this.pushEvent("set_nickname", { nickname: nickname });
};
But is this enough? A user can pushEvent
with an arbitrary payload from DevTools
let el = document.querySelector('[data-phx-session]')
let view = liveSocket.getViewByEl(el)
let hookContext = {
el: el,
viewName: view.name,
pushEvent(event, payload) {
return view.pushHookEvent(this.el, null, event, payload)
}
}
hookContext.pushEvent("set_nickname", { nickname: null })
So, we need to validate data on the backend
@impl true
def handle_event("set_nickname", %{"nickname" => nickname}, socket) do
if is_binary(nickname) and nickname != "" do
{:noreply, assign(socket, :nickname, nickname)}
else
{:noreply, socket}
end
end
Which makes the system safe, but introduces logic duplication. Of course, we can just remove validation on the frontend. But what if our frontend had a reason to be concerned with the data validity? For example, if we want to cache a nickname in localStorage and prompt for it only if it’s not present yet, instead of asking the user to enter it every time they load the page.
mounted() {
let nickname = localStorage.getItem("slick:nickname");
if (typeof nickname !== "string" || nickname.length == 0) {
localStorage.removeItem("slick:nickname");
do {
nickname = prompt("Your nickname");
} while (nickname == null || nickname.length == 0);
localStorage.setItem("slick:nickname", nickname);
}
this.pushEvent("set_nickname", {
nickname: nickname
});
},
Now frontend needs to know whether a nickname is valid to cache it and backend needs to know if it’s valid to use it for a current user.
And this our first lesson: web applications are distributed systems and we have to follow the rules they work or endure pain. In this specific case we are duplicating knowledge (of what constitutes a valid nickname) and will have to keep extending it in two places as nickname validation gets more sophisticated. My experience suggests economic incentives will always be against doing the extra work of trying to keep them in sync. Eventually, that would become a chore, then an obstacle, and at some point, unifying them would become a sizeable refactoring project.
We can shortcut that path by answering one question: where does the knowledge of what constitutes a valid nickname belong? The question is a bit tricky because the nature of the validation drifted from just input validation to a business rule (one can aruge it always was!). And that means it should sit on the backend without any doubts.
Fortunately, LiveView’s bi-directional communication protocol makes implementing that flow really easy..
@impl true
def handle_event("set_nickname", %{"nickname" => nickname}, socket) do
if is_binary(nickname) and nickname != "" do
{:reply, %{accepted: true}, assign(socket, :nickname, nickname)}
else
{:reply, %{accepted: false}, socket}
end
end
mounted() {
let nickname = prompt("Your nickname");
this.pushEvent("set_nickname", {
nickname: nickname
}, (reply) => {
if (reply.accepted) {
localStorage.setItem("slick:nickname", nickname);
}
});
}
This is also makes us acknowledge that frontend can’t enforce validation. And that’s why the do/while
loop is gone. Fighting against it is a lost battle.
And I hope that makes a valid case for why having the right mental models for distributed systems is important. You can be implementing something that’s not worth doing and expending energy that could bring more benefits if applied to other areas.
With validation ownership sorted out, we face another distributed systems challenge: handling incomplete or missing data gracefully.
The leading question opening this topic is what we should do if the user doesn’t provide a nickname? They can just click “cancel” on the prompt and not be bothered. A few options we have are to keep nagging them or provide a default value and then allow them to change it if they want.
The last option is of particular interest because it reveals that there is something that can mask itself as nothing. Or, rather, we may not see that something and perceive it as “nothing” because we don’t distinguish between “nothing” and “empty”. It really takes a bit of training, so let’s use an example.
For now we are just going to provide a default nickname
@impl true
def mount(%{"room" => room}, _session, socket) do
{:ok, assign(socket, :nickname, UniqueNamesGenerator.generate([:adjectives, :animals]))}
end
<!-- show.html.heex for /rooms/:room -->
<div id="show-page" phx-hook="LoadNickname" >
<span>{@nickname}</span>:
export const LoadNickname = {
mounted() {
this.pushEvent("load_nickname", { nickname: localStorage.getItem("slick:nickname") });
},
};
@impl true
def handle_event("load_nickname", %{"nickname" => nickname}, socket) do
if valid_nickname?(nickname) do
{:noreply, assign(socket, :nickname, nickname)}
else
{:noreply, socket}
end
end
This works fine for until user didn’t provide a nickname. After that, upon every page load a pregenerated nickname will flick on a screen until LiveView process would receive an update from cache.
This flickering reveals an important concept: the distinction between ’empty’ (not yet loaded) and ’none’ (definitively absent) states.
As often happens when a process boots, it isn’t necessarily immediately aware of the world’s state and has to catch up—for example, by loading from a persistent store. Meanwhile, the system may have already signalled readiness to its collaborators and started accepting incoming transactions.
This is “eventual consistency” in action: your system might not have the latest state yet, but it maintains valid state throughout, with clear rules for handling updates without creating inconsistencies.
Equipped with that mindset let’s resequence state transitions:
1). The system loads up but it doesn’t know whether there is a cached nickname or not. So it provides an empty
state. In this very naive example let’s just assume nickname = nil
would do.
2). The UI enters that empty
state and displays load indicator. It further doesn’t yet allow to interact with the system, until it’s fully loaded up. In our case - won’t accept messages
3). It receives a value from cache. If the value is empty it provides a useful default that a user can later change.
4). After system entered main loop pregenerated data can be changed by a user
This story is logical and easy to follow. Implementation is not bad as well.
Let’s start with an empty state
@impl true
def mount(%{"room" => room}, _session, socket) do
{:ok, assign(socket, :nickname, nil)}
end
Now, let’s give user an ability to change it after the page is loaded
<!-- show.html.heex for /rooms/:room -->
<%= if (is_nil(@nickname)) do %>
<span>
Loading...
</span>
<% else %>
<span id="nickname-switcher" phx-hook="SwitchNickname">{@nickname}</span>:
<% end %>
export const SwitchNickname = {
mounted() {
let nickname = prompt("Your nickname");
this.pushEvent("set_nickname", { nickname: nickname }, (reply) => {
if (reply.accepted) {
localStorage.setItem("slick:nickname", nickname);
}
});
},
The key here is an “empty” or “initial” state.
Embracing the eventual consistency can free us up from a lot of limitations of synchronous data processing and enable us to build neat reactive UIs through Pub/Sub. It’s a a really cool pattern that can be used to a great benefit with LiveView in many different scenarios.
Using LiveView and Elixir can help us learn rules of distributed systems. Embracing them can help us build robust applications that tells a story of consistency, logical sequences and therefore are pleasant to work with and easy to maintain. But building those systems takes discipline and effort.
In this example we saw how understanding who’s in control of the state gave us an ability to clearly separate responsibilities refactoring from
mounted() {
let nickname = localStorage.getItem("slick:nickname");
if (typeof nickname !== "string" || nickname.length == 0) {
localStorage.removeItem("slick:nickname");
do {
nickname = prompt("Your nickname");
} while (nickname == null || nickname.length == 0);
localStorage.setItem("slick:nickname", nickname);
}
this.pushEvent("set_nickname", {
nickname: nickname
});
},
to
export const LoadNickname = {
mounted() {
this.pushEvent("load_nickname", { nickname: localStorage.getItem("slick:nickname"); });
},
};
and
export const SwitchNickname = {
mounted() {
let nickname = prompt("Your nickname");
this.pushEvent("set_nickname", { nickname: nickname }, (reply) => {
if (reply.accepted) {
localStorage.setItem("slick:nickname", nickname);
}
});
},
We then further got rid of the race condition when a useful default competed with a value from cache to fill the empty cell of the initial state.
Just two days after completing the first draft for this blog post, I came across Think Distributed Systems by Dominik Tornow . I must warn - I’m just at the beginning, yet I’m enjoying it a lot and can fully recommend grabbing a copy. It’s a great read so far!