CONCRETE 007: Reporting to the Server from a Client Action.

Summary:

Concrete 002's counter runs entirely client-side. The + button never talks to the server at all. This post covers the next step: keep the click instant, but also tell the server what just happened, still written entirely in Erlang — no hand-written JavaScript, no separate API endpoint to wire up by hand. This needs a current concrete checkout; if it's been a while since you pulled, update it first.

The Pattern

action/3 is compiled to JavaScript and runs in the browser. command/3 is different: concrete_command_handler runs it for real, server-side, on the real BEAM, in response to a POST to /concrete/command (concrete_ws_handler reaches it the same way over a WebSocket). Wiring one to the other from inside a compiled action needs ?js:call to reach Client.dispatchCommand, the same function a declarative concrete-command button attribute would call, but with real arguments instead of an always-empty params map:

-include_lib("concrete/include/concrete_js.hrl").

%% Compiled to JS, runs in the browser. The count update itself never
%% waits on the network -- dispatchCommand fires and the render happens
%% either way.
action(increment, _Params, #{state := #{count := N} = S} = C) ->
    NewCount = N + 1,
    ?js:call(<<"Client">>, dispatchCommand,
             [<<"report_count">>, #{count => NewCount}]),
    C#{state => S#{count := NewCount}}.

%% Runs server-side, dispatched by concrete_command_handler.
command(report_count, Params, Server) ->
    io:format("counter is now: ~p~n", [Params]),
    Server.

Click +, and command/3 runs on the server with Params = #{<<"count">> => 1}.

Multiple Commands, and Binary Params

command/3 dispatches the same way action/3 does: one clause per command name, matched on the first argument. A page usually needs more than one:

action(increment, _Params, #{state := #{count := N} = S} = C) ->
    NewCount = N + 1,
    ?js:call(<<"Client">>, dispatchCommand, [<<"report_count">>, #{count => NewCount}]),
    C#{state => S#{count := NewCount}};
action(reset, _Params, #{state := S} = C) ->
    ?js:call(<<"Client">>, dispatchCommand, [<<"log_reset">>, #{}]),
    C#{state => S#{count := 0}}.

command(report_count, #{<<"count">> := Count}, Server) ->
    io:format("counter is now: ~p~n", [Count]),
    Server;
command(log_reset, _Params, Server) ->
    io:format("counter was reset~n", []),
    Server.

The #{<<"count">> := Count} pattern is worth pausing on: Params arrives straight from thoas:decode/1, the JSON decoder, with no wire-tagging and no atom conversion step. Keys are binaries, not atoms — #{count := Count} will never match. This is a JSON map, not a Concrete term.

command/3 Runs Ordinary, Unrestricted Erlang

command/3 only ever runs server-side, on the real BEAM — never as compiled JS, regardless of transport (HTTP or WebSocket). That means it can do anything ordinary Erlang can do: hit a database, call a gen_server, spawn a process, write to a log file. Nothing about it needs to stay browser-safe. A command that persists the count in a real process, instead of just logging it, looks like any other gen_server client code:

command(report_count, #{<<"count">> := Count}, Server) ->
    ok = counter_store:record(Count),
    Server.

counter_store here is a plain gen_server — nothing about it is Concrete-specific, and nothing it does needs to compile to JavaScript, because it never will.

Sending Data Back To The Browser

command/3's return value is not discarded. Whatever keys the returned Server map carries get merged into the component's rendered state on the client (client.js's dispatchCommand does this with Interpreter.mapUpdate after the response comes back), and the page re-renders. That gives the server a way to correct or extend what the browser is showing, not just observe it:

command(report_count, #{<<"count">> := Count}, Server) ->
    Clamped = max(0, min(Count, 100)),
    Server#{count => Clamped}.

If a client ever sends a count outside the allowed range — a stale tab, a modified request, whatever — the next render shows the clamped value instead, without action/3 needing to know anything about the limit itself. The Server map is otherwise opaque to render/1; it's not the component's own state until a command merges a key into it, so returning Server unchanged (the common case, as in the earlier examples) is a deliberate no-op, not a formality.

A Complete Page

Putting the pieces together — instant client-side updates, a clamped, authoritative count reported to the server on every click:

-module(clowning_page).
-behaviour(concrete_page).
-concrete([{route, "/"}]).
-export([init/2, template/0, action/3, command/3]).
-include_lib("concrete/include/concrete_js.hrl").

init(_Params, Server) ->
    {#{state => #{count => 0}}, Server}.

template() ->
    "page.slab".

action(increment, _Params, #{state := #{count := N} = S} = C) ->
    NewCount = N + 1,
    ?js:call(<<"Client">>, dispatchCommand, [<<"report_count">>, #{count => NewCount}]),
    C#{state => S#{count := NewCount}};
action(decrement, _Params, #{state := #{count := N} = S} = C) ->
    NewCount = N - 1,
    ?js:call(<<"Client">>, dispatchCommand, [<<"report_count">>, #{count => NewCount}]),
    C#{state => S#{count := NewCount}}.

command(report_count, #{<<"count">> := Count}, Server) ->
    Clamped = max(0, min(Count, 100)),
    io:format("counter is now: ~p~n", [Clamped]),
    Server#{count => Clamped}.
<div class="page">
  <h1>Welcome</h1>
  <p>Count: {@count}</p>
  <button concrete-click="increment">+</button>
  <button concrete-click="decrement">-</button>
</div>

What Comes Next

concrete-command button attributes cover a simpler, fully declarative case, but only ever send an empty params map — there is no template syntax yet for attaching dynamic, current-state params to one directly, which is why ?js:call is the method this post covers. The WebSocket path (concrete_ws_handler, which dispatches both action and command entirely server-side over one live connection instead of a fresh request per click) is still open for a post of its own.

Resources: