Designing Global Application State for Functional Frontends
The biggest idea in frontend development today is DOM as a function of state. It’s a game-changing concept that proved particularly effective for Hootsuite streams, which are essentially a function of social network data and a user’s interaction with that data.
Today, Hootsuite streams are built in React and use Flummox to manage application state. But that’s just an implementation detail – the core of our product lies in the way we organize our data and the functions that transform it into views. In this article, I’ll be presenting a glimpse of the application state driving Hootsuite Streams and more generally, things to consider when designing global state trees for functional frontends such as React.
What is a functional frontend?In this article, I use the term functional frontend to refer to something that looks roughly like the following at a high level:
In the React world, a component is a function that transforms some part of the state, expressed as props, into DOM. To keep this article framework agnostic, I’ll use the term view function to refer to a function that takes state as input and returns DOM as output.
Generally, it’s more practical to build multiple view functions to transform state into views. Going back to the diagram, a real world f(x) is probably composed of g(x), h(x), and a bunch of other functions operating on different parts of the state generating different DOM nodes on the page. Thus, it’s critical to design an application state that is easily consumed by the hierarchy of view functions driving your user interface.
Note: This article discusses the design of global state, which refers to a single shared state tree from which view functions receive input. This is not the only way to store state – another approach is to use local state in view functions and pass them to children as needed. These strategies are by no means mutually exclusive – it is often practical to have both a global state throughout the app and local states within view functions.
It’s been 8 months since the Engagement team began adapting Hootsuite streams to fit a functional frontend, and there are plenty of lessons we’ve learned and continue to learn along the way.
Idea 1: Normalize Data Received from the Backend
Hootsuite customers can add multiple streams to their dash, to engage with content and audiences from different social networks and streams. In the example above, hypercat is viewing news in their twitter home stream and keeping tabs on followers in their twitter mentions stream. The intuitive way to represent these streams in our application state is with two lists containing messages:
Although this structure appears to work at first glance, it shouldn’t take long to realize it’s possible for one message to exist in more than one stream. In that case, we’d face the following issues when using the model described above:
- Duplicate data: The same message is stored in two lists, which wastes memory.
- Multiple sources of truth: Let’s say _hypercat_ likes the cat burrito tweet from their home stream. We increment the like count on that message. Now the cat burrito tweet is out of sync in the mentions stream – the like count is off by one. A workaround would be to increment the like count on both messages, but this process is expensive and unsustainable.
Maintaining lists of messages doesn’t work, but what about maintaining lists of message references? We could store all the message objects, and refer to them by their unique message IDs like so:
This process is formerly known as database normalization, and it’s a strategy we apply in our frontend to minimize data redundancy. Now our state tree is comprised of two maps, one for message lists and one for messages. A messageList record is meant to represent a stream, and therefore contains a list of message IDs representing the messages in the stream. In terms of the view functions that would be consuming this data, we could have something that looks like this:
If one message is mutated in the state, the change will flow into all streams since our MessageList view function is fetching every message from the same message map outlined earlier. And since we’ve normalized the data, updating a message is just a matter of mutating that one message in the map. Fetching a message is as simple as keying into the message map by ID.
Idea 2: Determine what each view is a function of and build your state from there
Hootsuite customers can connect a number of social networks to their accounts and view streams through different perspectives. As a result, every message in a Hootsuite stream comes with a unique set of engagement actions tailored specifically for that customer and message. Unlike the actual message content, these icons are a function of both the message and the perspectival data associated with the message. Let’s take a look at this YouTube video:
The table below describes some of engagement icons in the screenshot above, and how they’re rendered:
|Icon||Property||A function of|
|1. Like||Color||Perspectival information If the authenticated user liked the post, return blue. Otherwise, return grey.|
|2. Dislike||Visibility||Message If the message source is YouTube, return true. Otherwise, return false (as of today, no other social networks on Hootsuite support the dislike action).|
|3. Delete||Visibility||Perspectival information and message If the API supports deletion on the message type and the authenticated user has permission to delete the post, return true. Otherwise, return false.|
Since many of our UI elements care about perspectival data, we must include it in our application state somewhere. We have a list of messages, but we can’t persist perspectival information on a message since one message could be viewed from multiple perspectives. We have a list of messageList records, but we can’t store perspectival information in those since we’d be duplicating data when rendering a message in multiple streams logged in from the same account. Our only option is to create a new branch in our state tree – a map that stores perspectival information, keyed by the authenticated user’s ID. On our team, this is known as the message context, and it looks something like this:
This is nice for a number of reasons. Firstly, we can represent multiple perspectives on the same message since our messageContext map is keyed by user ID. Secondly, we don’t have to worry about duplicate message contexts when the same message appears for a user in multiple streams. A user’s message contexts are keyed by messageId, so there can only be one context for a user on any given message.
The general lesson being presented here is how you can derive a state tree from examining the functions generating your views. In this example, we identified that our engagement icons were a function of message and perspectival data. Then, we analyzed how perspectival data fit into our state conceptually – for example, every message has perspectival information associated with it, different users have different perspectives on the same message, and the same user has the same perspective on a message regardless of which stream it appears in. With those principles in mind, and knowing our view was a function of message and message context, we naturally arrived at our solution of storing perspectival information as a map keyed by user ID and message ID.
About the Author
Dian Jin is studying Software Engineering at the University of Waterloo.