Hey I'm Matt, the creator of Kollabe. A planning poker and retrospective app that is written in react, typescript, with NextJS. For those unfamiliar, planning poker is an agile estimation technique used by development teams to estimate the effort required for user stories or tasks.
When I created my planning poker app, I did so in such a way that having a Demo room for it was relatively simple. So I wanted to talk about the architecture I used and how you might also do the same thing.
Let's quickly go over the basics of how the actual app works first.
The Basics
The planning poker app on Kollabe works like this.
A user makes a change to the state, like voting, creating a ticket, sending a message, etc.
The local zustand state is updated optimistically. This means the changes happen instantly before the API call is made. If the call fails, we have a mechanism to rollback the changes. I'll leave an example below.
There is an API call to the server to save the new state into our persistence layer. The response from this is ignored if it is successful.
The server saves the new state into the persistence layer, and pushes the new changes down to all of the connected clients via WebSockets.
All of the clients that are connected to the websocket for this session receive the new message and update the games state using the clients Zustand store.
Here's an example of how we handle optimistic updates in our state management:
// This is used internally, inside of this state slice, and also from our WebSocket layer.
updateTagInState: (tag) => {
get().setRoom((prev) => {
const tagIndex = prev.tags.findIndex((t) => t.id === tag.id);
if (tagIndex != -1) {
prev.tags[tagIndex] = tag;
return {
...prev,
tags: [...prev.tags],
};
}
return {
...prev,
tags: [...prev.tags, tag],
};
});
},
// This is used on the application layer. User interactions call updateTag.
updateTag: async (updatedTag) => {
const currentTag = get().room.tags.find((t) => t.id === updatedTag.id);
get().tagsState.updateTagInState(updatedTag);
try {
const req: UpdateTagRequest = {
roomId: get().room.id,
tagId: updatedTag.id,
name: updatedTag.name,
color: updatedTag.color,
};
await axios.post(API.updateTags, req);
} catch (e) {
get().setError("updateTag", e);
get().tagsState.updateTagInState(currentTag);
}
}
The Layers
Based on this, you can see that there are essentially three layers to the application.
Client state. We use Zustand to handle all of the games state. This means fetching, updating, and storing.
Server state. This handles receiving new updates, and pushing them down to the clients. The clients never talk to each other, messages always go to the server first.
Websocket layer. This layer is responsible for receiving updates from the server and updating the zustand store with the changes.
I mentioned it earlier, and you'll also notice it if you look at the image above. For the most part, we don't care about the server's response when making updates. If the server responds with an error we gracefully handle that, but otherwise the client handles the majority of the logic. This means we can completely remove this layer for our demo.
The Demo Application
Since our application makes all of its updates optimistically, we can just mock our API layer and always return 200. This means any changes we make in our demo, as the user, will just happen.
I use Axios for my API layer, so it is pretty to add an interceptor that will do just this. Here is my interceptor:
/**
* This interceptor is used to mock the response of the room demo. Since we make all of our state changes
* on the client-side, we don't need to make any API calls to the server. This interceptor will intercept
* all requests to the room API and return a 200 response with an empty body. This means we need to ensure
* that the client-side state is always in sync with the server-side state.
*/
instance.interceptors.request.use(
(config) => {
if (
(config.url.includes("api/room") &&
window.location.pathname.includes("/room/demo")) ||
(config.url.includes("api/retro") &&
window.location.pathname.includes("/retro/demo"))
) {
config.adapter = (internalConfig) => {
const res = {
data: {},
status: 200,
statusText: "OK",
headers: { "content-type": "text/plain; charset=utf-8" },
config: internalConfig,
request: {},
};
return Promise.resolve(res);
};
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
With the API mocked, let's look at handling the "fake" users in the demo. Great. Now all of the user interactions work perfectly. But what about the "Fake" users? For that we create a new layer. This is the demo layer. It controls creating all of the events for the demo. It works the same way our WebSocket layer worked previously. It will create fake events and call the game's zustand store to update with these events. To start the demo, the user interacts with the app.
That is essentially it. There are a lot of specific implementation details that I breezed over, but I think that covers the architecture at a high level. If you want, you can check out the planning poker demo on Kollabe here. The code is not open source at the moment, but feel free to reach out if you have any other questions!
I'd love to hear what you think, or if you have ever implemented anything similar. Thanks for reading!
Top comments (4)
I where a specific reason why the scrollbar is hidden/suppressed on your website?
kollabe.com/
I find it really irritating then developers find the need to consciously reduce the accessibility of their own websites without any valid reasons.
Totally agree, and no there isn't. Just a learning curve that I had forgotten to correct. Thanks for pointing it out
thanks you, great
Phew, thanks to articles like this I am growing. I would never have thought of this on my own. Just wow. I recently found Avantgarde casino no deposit bonus and used casinosanalyzer.com/casino-bonuses... for this. I realized that not everyone even has the app. I can imagine how difficult it is to create it. Never mind, in the future I will create something too. I still have everything ahead.