Our experience combining Elixir with React has been great and fruitful. They share similar paradigms and make you think about state as source of truth and functional programming.
There is a particular case where they really shine when used together, which is when you combine a React+Redux client with an Elixir GenServer. Graphically, this is the schema of a Redux app:
Basically, Redux does the following:
- Views dispatch an action.
- Reducers process the action. Based on it and the current state a reducer produces a new version of the state.
- Once the state is updated, it is propagated to the views that are connected to (slices of) the state.
An OTP GenServer does the following:
- An event is dispatched to the server. It can be either synchronous (call) or asynchronous (cast).
- Callbacks process the event. Based on on it and the current state a callback produces a new version of the sstate.
And optionally:
- Once the state is updated, it can be optionally propagated to subscribers using for instance websockets if we have a PubSub mechanism like the one that ships with Phoenix the Elixir framework.
Graphically:
In short, we can use the same mental model for the client and server side.
The combination feels natural. We can dispatch events to Elixir, compute a new version state server side, propagate this state to redux, and our views will get updated automatically.
This makes possible to use a redux-like environment in applications like collaborative apps, or games, where several users can force state updates. It also makes sense when you want to enforce server-side restrictions of what states are valid in the application.
Show me some code
The following examples are from a simple tic-tac-toe game (code here).
Sending an event to the server
Let's follow the flow of action since our player clicks in the tile where he wants to make their move. We capture the click on the Tile, and we send a game:play
event to the server:
<Grid>
{board.map((row, idxRow) => (
<Row idx={idxRow} key={idxRow}>
{row.map((tile, idx) => (
<Tile
onClick={() =>
this.channel.push("game:play", {
idx,
idxRow
})}
key={idx}
idx={idx}
tile={tile}
/>
))}
</Row>
))}
</Grid>
Using Phoenix, the popular framework for Elixir, sending a PubSub event using websockets is easy. You just have to send the type of the event and the data. It feels very similar to dispatching a Redux action.
Graphically, we have just implemented the thick red arrow:
Updating the server state
The server side has two parts here. First, we define what to do with channels. This is similar to the concept of controllers. We are basically connecting wires here:
- Whenever we receive a
connection
, we will reply with the current state in the server. - Whenever we receive a
game:play
orgame:reset
, we will cast it to the GenServer.
#server/lib/tictactoe_web/channels/game_channel.ex
defmodule TictactoeWeb.GameChannel do
use Phoenix.Channel
def join("game", _message, socket) do
game = Tictactoe.GameServer.getGameState()
{:ok, game, socket}
end
def handle_in("game:play", %{"x" => x, "y" => y}, socket) do
Tictactoe.GameServer.play(x, y)
{:noreply, socket}
end
def handle_in("game:reset", _, socket) do
Tictactoe.GameServer.reset()
{:noreply, socket}
end
end
The GenServer part is more interesting. Here we are declaring our callbacks. They are similar to reducers. We receive an action with data, we will produce a new version of the state, and we will broadcast it to our subscribers.
We are making use of Elixir pattern matching. At a given time, only the callbacks that match the arguments received will execute. This way we can react in different callbacks to %{:action => :reset}
and %{:action => :play}
Note that the first callback, reacting to :get_game_state
is synchronous and will simply reply with the current state of the game.
#server/lib/tictactoe/game_server.ex
def handle_call(%{action: :get_game_state}, _, state) do
{:reply, %{board: to_list(state.board), phase: state.phase}, state}
end
def handle_cast(%{:action => :reset}, _state) do
state = %State{}
Endpoint.broadcast("game", "game_update", %{board: to_list(state.board), phase: state.phase})
{:noreply, state}
end
def handle_cast(%{:action => :play, :x => x, :y => y}, state) do
case can_move(state, x, y) do
:true ->
state = put_in state.board[y][x], "X"
state = state
|> check_finished
|> make_random_move
|> check_finished
Endpoint.broadcast("game", "game_update", %{board: to_list(state.board), phase: state.phase})
state
:false -> state
end
{:noreply, state}
end
The callback for the action :play
has more logic. We are setting the value in the tile, and if the game is not finished the opponent will make a random move. Anyways, the important part here is that after computing a new version of the state, we broadcast it to the client.
We have just implemented the thick red arrows:
Dispatching an action on updates from the server
We will update the store with the state that comes from the server in two different situations. First, when we join the game
channel (so we receive the state of the game ASAP, right after connecting). We will also update our store when we receive game_update
.
// src/Board.js
componentDidMount() {
const { updateGame } = this.props;
let socket = new Socket("ws://localhost:4000/socket", {});
socket.connect();
let channel = socket.channel("game", {});
channel
.join()
.receive("ok", payload => updateGame(payload))
.receive("error", resp => console.log("Unable to join", resp));
channel.on("game_update", payload => updateGame(payload));
this.channel = channel;
}
In order to establish a connection with our Elixir server, we can do it on componentDidMount
. There are better ways of isolating our side effects, using dedicated libraries like redux-saga for instance, but let's keep it simple in this example, as sagas will need an extensive explanation.
I recently gave a talk about Redux Saga, you can find the slides here.
For a great explanation on how to use websockets cleanly with Redux Saga see this post.
We have just implemented the thick red arrow:
Updating the store
updateGame
is an action that gets processed by our reducer, so we update the store with the incoming data. Reducers are typically implemented as switchs. This is no different to the collection of callbacks we use in Elixir. Maybe in the future, if this tc39 proposal to have pattern matching in JavaScript makes it into the standard we will be able to have the a more similar syntax to the one we have in Elixir.
// src/reducer.js
// Action
export function updateGame(game) {
return { type: GAME_UPDATE, game };
}
// Reducer
export default function reducer(state = initialState, action = {}) {
switch (action.type) {
case GAME_UPDATE:
return { ...state, board: action.game.board, phase: action.game.phase };
default:
return state;
}
}
With this, we have connected the server state with Redux state. Graphically, the red thick arrows:
Connecting the views
What is left? For all of this to work we have to connect our views to the store using connect
. This is the code that connects our Board
to state.board
and state.phase
, the relevant pieces of the state.
const mapStateToProps = state => ({ board: state.board, phase: state.phase });
const mapDispatchToProps = dispatch => ({
updateGame: game => dispatch(updateGame(game))
});
export default connect(mapStateToProps, mapDispatchToProps)(Board);
With this, when the Redux store is updated, Board will render again taking into account the new state.
And with this we have just completed the cycle!
This is a really nice combination. If we want state to be local to one component, just use local state. If you want global state in the client application, put it in Redux. But, if you want to have state shared between different users, or we want to have pieces of the state in the server for other reasons, such as enforcing validation rules, we can use the same mental mode with Elixir GenServers.
Note: We could have used Erlang gen_servers instead, since they are the same thing as Elixir GenServers, but we like the Elixir syntax better.
If you want to play with the source code of this example project check its (Github repo).