Topical is an Elixir library for synchronising server-maintained state (topics) to connected clients. Topic lifecycle is managed by the server: topics are initialised as needed, shared between subscribing clients, and automatically shut down when not in use.
The accompanying JavaScript library (and React hooks) allow clients to easily connect to topics, and efficiently receive real-time updates. Clients can also send requests (or notifications) upstream to the server.
See the Getting started guide.
In its simplest instance, a topic's state can be ephemeral - i.e., discarded when the topic is shut down. For example, for synchronising cursor positions of users (see canvas example).
Alternatively state could be persisted - e.g., to a database - with the topic subscribing to updates from the database, which allows separating mutation logic, and replication of topics. In the case where lower durability can be afforded, state can be periodically flushed to disk.
Topical solves a similar problem to Phoenix LiveView, but at a different abstraction level, by dealing only with the underlying state, rather than rendering HTML and handling UI events.
There are WebSocket adapters for Cowboy and WebSock (compatible with Plug and Bandit), which allow adding Topical into an existing application. Either of these are required to support the JavaScript client and the full functionality of Topical. See examples/todo
for an example of both (running simultaneously).
Additionally, a REST-like adapter provides a way for clients to capture a snapshot of a topic (which is useful for supporing the incremental cache use case).
A partial implementation of a todo list topic might look like this:
defmodule MyApp.Topics.List do
use Topical.Topic, route: ["lists", :list_id]
# Initialise the topic
def init(params) do
# Get the ID from the route (unused here)
list_id = Keyword.fetch!(params, :list_id)
# TODO: subscribe to events from, e.g., database/pub-sub
# TODO: load list from, e.g., database/API
value = %{items: %{}, order: []}
{:ok, Topic.new(value)}
end
# Handle a message - e.g., from subscription
def handle_info({:done, id}, topic) do
topic = Topic.set(topic, [:items, id, :done], true)
{:ok, topic}
end
# Handle a request from a connected client
def handle_execute("add_item", {text}, topic, _context) do
id =
topic.state.items
|> Enum.count()
|> Integer.to_string()
# Update the topic by putting the item in 'items', and appending the ID to 'order'
topic =
topic
|> Topic.set([:items, id], %{text: text, done: false})
|> Topic.insert([:order], id)
# Return the result (the ID), and the updated topic
{:ok, id, topic}
end
end
And a corresponding React component:
import { SocketProvider, useTopic } from "@topical/react";
function TodoList({ id }) {
const [list, { execute, loading, error }] = useTopic("lists", id);
const handleAddClick = useCallback(
() => execute("add_item", prompt()),
[execute]
);
if (loading) {
return <p>Loading...</p>;
} else if (error) {
return <p>Error.</p>
} else {
return (
// ...
);
}
}
function App() {
return (
<SocketProvider url="...">
<TodoList id="foo" />
<TodoList id="bar" />
</SocketProvider>
);
}
See examples/todo
for a more complete example.
examples/todo
- A more complete todo example, with basic persistence.examples/canvas
- A simple canvas drawing example, with synchronised cursors.examples/game_of_life
- Conway's Game of Life.examples/cache
- Using Topical as an incremental cache.
Documentation is available on HexDocs.
This repository is separated into:
server_ex
- the Elixir library for implementing topic servers, including adapters.client_js
- the vanilla JavaScript WebSocket client.client_react
- React hooks built on top of the JavaScript client.
Topical is released under the Apache License 2.0.