Extreme Decoupling
React, Redux, SelectorsBy Baz on Oct. 4, 2016
See the demo and source on github
UIs can be seen as having three distinct layers:
- view: components, html, styles, layout, etc.
- data: state, text, labels, properties, etc.
- integration: selectors, derived data, event handlers, etc.
It is beneficial for these layers to remain separate to maximize maintainability and reusability, while minimizing complexity and iteration time (hence the rise of MV* frameworks).
Coupling is the Devil
The problem is, it is difficult to consistently identify, and adhere to, the right division of responsibilities. Sometimes the lines are blurry, other times it is too easy to overload existing abstractions instead of creating new ones. QA'ing is even harder. There is too much contextual knowledge and subtlety involved for code reviews to catch anything meaningful. Eventually logic leaks between concerns, the lines muddy, the cycle repeats, and a refactor is required. I would argue that managing coupling is the single most difficult and important responsibility of a lead engineer or architect.
Monolithic UIs
The way we structure our apps exacerbates the situation. The current state-of-the-art is to have a monolithic UI project broken into features folders (i.e. todos, comments, etc.) and subfolders of concerns (i.e. actions, components, etc.). The right code should go in the right places, but the app runs with few repercussions either way. Developing a feature involves working tightly between view, state and selectors, treating them like a single mass, with few clear divisions. The process of deciding where to implement abstractions becomes a subtle skill-driven task with little guidance.
Hello Interfaces
Rather than loosely managing concerns in 'layers', we can turn them into first-class programmatic citizens. We can encapsulate the entire view, state and selectors each in an independent stand-alone object that exposes a structured interface. Putting together a final app becomes a process of integrating defined APIs - a process of deliberately and judiciously introducing coupling at specific points of an app. For example, the following code imports an encapsulated view, and renders it with data and logic imported from a selectors object:
// import view
import { render } from 'my-entire-view';
// import selectors
import { selectors } from 'my-selectors';
// get dom element to render ui in
const domElement = document.getElementById('app');
// render full app
render(selectors, domElement);
This code has no dependency on things like react, or redux, or anything at all for that matter. Those are implementation details blackboxed inside their respective components. Each import is an independent self-contained object that exposes a minimal interface.
Here is what the interfaces could look like:
View Interface
The view's primary purpose is to export a render
function that renders the full UI:
export {
render(data, domElement): "function that renders view with given data and dom element",
constants: "object of constants related to view"
}
State Interface
State stores data and provides actions that can transform it.
It exports an interface with getState
, actions
and subscribe
:
export {
getState(): "function that returns latest state",
actions: "object of available actions",
subscribe(callback): "function that invokes a callback every time state changes",
constants: "object of constants related to state"
}
Selectors Interface
Selectors take state, and turn it into view-friendly data structures.
It is where coupling between state and view is isolated.
Its primary export is a property by the same name selectors
:
export {
selectors: "object of all selectors",
actions: "object of available actions",
state: "getter that returns latest state",
subscribe(cb): "function that invokes callback every time state changes",
constants: "object of constants combined from view, state and new constants"
}
Ethos
With these interfaces we can build many combinations of apps, with high code-reuse. For example we can make an entirely new view (i.e. for mobile, or a different locale, or a different audience, etc.) and plug it into the same shared state container. Or we can make a new state container with custom logic that plugs into an existing view. State and view remain fundamentally separate, generalized for many purposes, while selectors do the dirty work of coupling.
Let's See Some Code
There are many ways and technologies to implement these objects. It doesn't really matter which to use, as long as the interfaces are fulfilled. With that said, react and redux are especially well-suited to the task.
A full example todo app using react, redux, and selectors, is available on github:
View Object (React)
The primary goal of the view object is to export a render
function that renders the UI using passed in data
.
Component-based view frameworks like react make this easy.
React allows for the hierarchical composition of encapsulated view elements into a single top-level component that can render the entire UI.
Here is an example of a render
implementation that uses react to render the top-most visual element, which renders all other child elements:
import React from 'react';
import { render as reactRender } from 'react-dom';
import TodosPage from './todos/todos-page';
export default function (data, domElement) {
let page = <TodosPage { ...data.todos } siteHeader={ data.siteHeader } />;
// render to dom using react
reactRender(page, domElement);
}
The view could export itself and render
like this:
import render from './render';
import * as TODO_STATUSES from './todos/constants/statuses';
// export api for other apps to use
export default {
render,
constants: {
TODO_STATUSES
}
};
A 3rd-party app could implement it like this:
// import view
import { render, constants } from 'todo-react-components';
// render entire ui with empty data
render({}, document.getElementById('app'));
// dump available constants
console.log(constants);
There are no backend services, or state containers or ajax requests or business logic in the view object - only a hierarchy of react components responding to passed in data. It is built selfishly in isolation with the sole goal of modeling itself in the simplest possible way. This helps ensure that:
- react components remain minimal
- react component logic is limited to display concerns
- react components are generalized and reusable for multiple purposes
- react components can be developed with smaller display-specific skill sets
The data
argument of render
is a generic object that contains all the data the view may need, in the shape the view defines.
It is the responsibility of implementers to provide a correct structure that allows the view to work.
Here is an example of data
for a simplified todo app:
{
"selectedPage": "HOME",
"url":"/",
"todos": {
"newForm":{
"placeholder": "What do you need to do?"
},
"list":[
{
"description": "Buy tomatoes from grocery store",
"dateCreated": "2016-09-19T18:44:15.635",
"isComplete": false,
"id": "10",
"buttonLabel": "delete"
}
]
}
}
Tips on structuring view data:
- prefer arrays over objects
- prefer nested, hierarchical, denormalized data
- name things relative to UI elements not domain knowledge: 'onClickButton' instead of 'onClickAddTodo'
- name things with as little specificity as possible, but no less
- pass in all strings, labels, and text as props
- prefer props over state
- only use local state for: forms, performance, temporary data, or special circumstances
- de-structure objects into individual props as you descend down the hierarchy tree
- group inner component interfaces into higher-level objects as you climb up the hierarchy tree
- child components should have no knowledge of parent components or higher-order structures
State Object (Redux)
The state object holds all of an app's data, and offers actions that transform it in well defined ways.
Its primary exports are getState()
, which returns a snapshot of the latest state, and actions
, which is a collection of available transformations.
Here is an example of code importing a state object, running some actions, and logging the new state between them:
// import the state project
import { getState, actions } from 'todo-redux-state';
// run load-todos action
actions.todos.loadTodos();
console.log(getState());
// run add-todo action
actions.todos.addTodo('demo test 1');
console.log(getState());
// run remove-todo action
actions.todos.removeTodo('3');
console.log(getState());
Redux is a state container. It is unrelated to views, or react, and can be used for many purposes. It manages the transformation of data based on defined actions. The key insight of redux is that it groups all mutations of a particular part of state in the same place, regardless of what originating actions triggered them. This makes it easy to reason about changes over time. Redux is an excellent choice of technology for the state object. Here is an example export of a redux-powered state object:
// import redux store
import store from '../src/store';
// import actions
import addTodo from './todos/actions/add-todo';
import loadTodos from './todos/actions/load-todos';
import removeTodo from './todos/actions/remove-todo';
// import constants
import * as TODOS_STATUSES from './todos/constants/statuses';
// export interface
export default {
getState: store.getState, // borrow from redux
actions: {
todos: {
addTodo,
loadTodos,
removeTodo
}
},
subscribe: store.subscribe, // borrow from redux
constants: {
TODOS_STATUSES
}
};
Having a dedicated state object allows state to remain as simple and minimal as possible. Guidelines:
- state should be flat, shallow, normalized, and flexible
- prefer objects over arrays
- avoid nesting objects, use ids to denote relationships
- any value that can be derived or calculated should not be stored in state
- name things relative to domain, not visual elements: 'addTodo' instead of 'onClickButton'
Selectors Object
Now that we have the two pillars of an app in a beautiful, maintainable, decoupled form, how do we combine them into a functioning app? How do we populate a rich, hierarchical, nested view that was built in isolation, using simple, flat, shallow state that was also built in isolation?
Selectors the unsung heros
Selectors are functions that take state, and turn it into data for views. For example, say our state had a collection of todos like this:
{
"#123": { "description": "buy groceries" },
"#456": { "description": "book flight" }
}
But the view required an array of todos like this:
[
{ "id": "#123", "description": "buy groceries" },
{ "id": "#456", "description": "book flight" }
]
Our selector would take the relevant parts of state and transform them into the final shape for the view:
/*
* todos selector
*/
export default function (state) {
// get relevant state
const { todos } = state;
// generate view-specific structure
return Object.keys(todos).map(key => {
return {
id: key,
description: todos[key].description
};
});
}
Whenever a selector is called, it runs on the latest snapshot of state, and returns an up-to-date result. Given the same state, a selector will return the same result.
Selectors do not introduce new data or functionality. They simply handle the dirty work of joining views to state. They allow views and state to evolve independently and freely.
Selector Guidelines
- selectors should be deterministic and free of side-effects
- selectors should only operate on state and constants
- selectors can be used by other selectors
- selectors should memoize all computation
Logistics
There are many architectural benefits to developing view, state and selectors as independent, stand-alone objects. There are many logistical benefits too. Consider the contrasting requirements between view and state when it comes to:
- testing methodology (integration tests vs unit tests)
- project dependencies (display libs vs data libs)
- required skill-sets (html/css vs js)
- build and deploy processes
The view may depend on react and some 'classnames' utility, while state could depend on redux, thunk, and agent. The view may need a selenium test suite, while state and selectors would focus on unit tests. The view favors a display-oriented mindset with skill in html, css, and design, while state is heavy on data transformation, remote services, data modeling, etc.
Multi Repo Approach
The final step to extreme decoupling, is to physically separate the concerns from each other. Make each concern its own complete, runnable, stand-alone project in its own repo. Each with their own focused dependencies, versioning, release schedules, test suites, file structure:
- Each concern can be worked on, versioned and deployed completely independently of the others
- Different teams with different skills can easily work on different concerns
- Each concern has a smaller set of dependencies to manage
- Each concern can have its own custom testing strategy
- Concerns can be shared normally through NPM
- The line between concerns further widens reaching an extreme degree
Todo Example on Github
For a complete implementation of all the concepts discussed, see the example todo app on github: