The Grammarly web editor uses a reactive architecture to respond to user inputs in real time. When you type something new or respond to Grammarly’s suggestions about your writing, those interactions generate data streams that the client immediately processes to update the UI.
These endless event streams can be hard to manage without the appropriate tools. We were spending a lot of time writing boilerplate code for subscribing to and unsubscribing from events, as well as for propagating events through our application logic.
To simplify this and standardize our reactive framework, we developed an abstraction called atoms based on the RxJS library. An atom is an observable object that, when its value changes, notifies listeners. Some frameworks use the term signals for the same idea. These observable objects can be highly nested or complex, so we also use an interface called lenses to simplify how we access atoms. Lenses enable zooming into a specific property within an object without making assumptions about the rest of the object’s structure.
Atoms and lenses are part of@grammarly/focal-atom, an open-source library for developers of any reactive application (we also have@grammarly/focal specifically for React apps available at the same link). In this article, we’ll cover the mechanics of this library for immutable and observable state management.
Observable state management with atoms
Let’s explore how atoms can be used to enhance a classic to-do app, namely theTodoMVC application. We’ll focus specifically on state management with atoms. For the full implementation, refer to ourTodoMVC with Focal example.
Here’s how you’d create an atom:
interface TodoItem {
title: string
completed: boolean
}
interface AppState {
userName: string
todos: Array<TodoItem>
}
const state = Atom.create<AppState>({
userName: "Joan",
todos: []
})
Recall the motivation for atoms is to broadcast state changes. For this toy example, we’ll just write to the console whenever the atom’s value changes, but you could imagine a more complex application where many disparate components subscribe to a particular atom and update accordingly.
state.subscribe((state) =>
console.log(`The app state was changed`, JSON.stringify(state)
)
To update an atom, you can either set its value explicitly or modify it according to a function applied to the existing value. To highlight both use cases, let’s reset the application state to an initial value and then add a new todo.
state.set({ todos: []}) // Reset the state to the initial value - no todos
state.modify(state => ({
...state,
todos: [...todos, { title: "A new todo", completed: false }]
})) // The new todo 'A new todo' was added
Each time the state is updated, the new value gets logged to the console per the event listener above. The listener executes its callback if and only if the atom’s value changed (and once when the subscription is first created). At their most basic level, atoms provide a way to couple data streams to listeners.
While you would typically interact with an atom’s value through an event listener, you can always use the `.get()` method to observe the current value of the object, which can be convenient for debugging:
state.get()
// { userName: "Joan", todos: [{ title: "A new todo", completed: false }] }
Using views and lenses for operating on complex objects
A powerful feature of the Focal library is the ability to compute a new value based on the one stored in an atom. It’s a best practice to maintain as few atoms as possible as your sources of truth and derive state whenever possible.
With the `.view()` method, you can retrieve a read-only version of an atom, preventing unintentional mutations. The method’s parameters specifyhowto view the atom (remember that atoms can store complex objects, not just individual values). For example, you can supply a field name in the parameters to read a specific field in the atom. Alternatively, you can provide a transformation that computes a value based on an atom’s contents.
const state = Atom.create<AppState>({
todos: [{ title: "A new todo", completed: false }]
userName: "Joan"
})
// Expose only the list of todos
const todos = state.view('todos')
// Expose only the number of todos
const numTodos = state.view(s => s.todos.length)
Lenses, a concept from functional programming, allow zooming in on a specific field within an atom. Lenses contain a getter and a setter, which are for reading and writing to an object with a particular field, without making any assumptions about the object’s other fields. They are similar to views except they are writable.
For example, in this code snippet we create a lensed atom that enables us to modify the `completed` property of all our todos in a bulk operation:
const state = Atom.create<AppState>({
todos: [{ title: "A new todo", completed: false }],
userName: "Joan"
})
const todoCompletedState = state.lens(Lens.create<boolean, boolean>(
// Getter
({ todos }) => todos.every(({completed}) => completed),
// Setter
(completed: boolean, state: AppState) => ({
...state
todos: state.todos.map(todo => ({...todo, completed })
})
)
todoCompletedState.set(true) // Will "complete" all todos
todoCompletedState.set(false) // Will mark all todos incomplete
Let’s look at the following diagram to better understand what’s going on. When the `.set()` method is called, the lens updates all atoms’ `completed` property to the given value. This gives us a fast way to check or uncheck all todos. On the other hand, the lensed atom’s `.get()` method provides the cumulative state for all todos, returning true only when all todos are completed.
When you define your own lens, you can implement whatever custom logic you want. Sometimes, it makes sense to install guardrails in your setter so the atom can only take on certain values.
In addition to custom lenses, the @grammarly/focal-atom library comes with ready-to-use lenses for arrays (`Lens.index()`) or dynamic lookup in objects (`Lens.find()`). To observe the very first todo, you can just use the `Lens.index()` helper, which we pass to the `.view()` method to create a read-only atom:
const state = Atom.create<AppState>({
todos: [{ title: "A new todo", completed: false }],
userName: "Joan"
})
const firstTodo = state.view('todos').view(Lens.index<TodoItem>(0))
firstTodo.get() // Return{ title: "A new todo", completed: false } or undefined if there are no todos
For more examples of atoms and lenses in practice, check out our repository.
Process data streams with RxJS
Focal is based on and fully compatible with the popular library RxJS, which includes all sorts of tools for working with observable values. You can treat an atom as an ordinary stream of events and use RxJS to process that stream in a declarative way.
For instance, here we use the RxJS functions pipe and pairwise to print the previous and new states each time the state changes:
const state = Atom.create<AppState>({
todos: [{ title: "A new todo", completed: false }],
userName: "Joan"
})
// Log the todos changes
state.view('todos').pipe(
pairwise()
).subscribe(([prevValue, newValue]) => {
console.log(`The state was changed from ${JSON.stringify(prevValue)}, to ${JSON.stringify(newValue)}`)
})
state.lens('todos').modify(todos =>
[...todos, { title: "The second todo", completed: false }]
)
state.lens('todos').modify(todos =>
[...todos, { title: "The 3rd todo", completed: false }]
)
Focal integrates with React
We have a related library, @grammarly/focal, that was developed to specifically work with React. With this library, you can directly embed atoms in JSX code.
There are two ways to go about this. A simple `lift()` function will make any React component work with observable values. Alternatively, we have wrappers for all basic HTML elements:
// Make existing React component work with Atoms
const todo = Atom.create({ title: "My first todo", completed: false })
const TodoItem = (props: {title: string, completed: boolean}) =>
<div>
<div>{props.title}</div>
<input type="checkbox" value={props.completed}/>
</div>
const LiftedTodoItem = lift(TodoItem) // This converts all props of TodoItem to allow Atoms
<LiftedTodoItem title={todo.view('title')} completed={todo.view('completed')} />
// Or use the Focal helpers
const TodoItem = (props: {todo: Atom<{ title: string, completed: boolean>}) =>
<div>
<F.div>{props.title}</F.div>
<F.input type="checkbox" checked={props.completed} />
</div>
<TodoItem todo={todo}>
Conclusion
We’ve been using atoms at Grammarly for years to manage data streams in a declarative way. And thanks to lenses, we find ourselves writing a lot less boilerplate code across all of our web clients. We’re also happy to see that the broader developer community is finding similar success with what other frameworks call signals. If you’re interested in trying atoms in your own projects, the@grammarly/focal-atom library has all the resources you need to get started. And if you want to work on a variety of cutting-edge engineering problems, we’re hiring, so take a look at our open roles today.