III. Layers
This is page three of a guide on Client-Side Architecture basics. Start at page one.
Layers and concerns
We're finally ready to decompose each part of Model-View-Presenter, especially the model part.
Here's a graphic to illustrate that decomposition into something more concrete.
Can you see both CQS and SoC in here?
Let's examine it from the top.
Presentation components
Render the UI and create user events
If you read the title and feel like closing the tab because of this article by Dan Abramov, hang in there. Just wait until we get to container components to decide if you want to bounce 🏀.
Presentation components live within the boundaries of the View portion of Model-View-Presenter. Their entire purpose is to:
- Display data in the UI
- Generate user events (from keypresses, button clicks, hover states, etc)
Presentation components are an implementation detail
An implementation detail is a low-level detail that helps us accomplish our main goal. But they're not our main goal. If our main goal is to hook up the Add Todo feature, the buttons, styling, and text in the UI is an implementation detail in realizing the feature.
Presentation components can be volatile
Anything subject to frequent change is said to be volatile. Us constantly changing the look and feel of components is what makes them so.
One way to accommodate this phenomenon is to decide on a stable set of reusable components (that either you wrote or grabbed from a component library), then create your views from those.
Even though we could use reusable components, data requirements change frequently.
Take this simple CardDescription
component that uses a GraphQL query to describe a card.
const CARD_DESCRIPTION_QUERY = gql`
query CardDescription($cardId: ID!) {
card(id: $cardId) {
description
}
}
`;
const CardDescription = ({ cardId }) => {
const { data, loading } = useQuery({
query: CARD_DESCRIPTION_QUERY,
variables: { cardId }
});
if (loading) {
return null;
}
return <span>{data.card.description}</span>
}
How likely is it that we'd need to change the styling? What about displaying something like a lastChanged
date beside it? Chances are we pretty likely.
Should we include GraphQL queries in our presentation components?
It's good to have GraphQL queries as close to the presentational component as possible. Queries define the data requirements. And since they'll likely need to be changed together if the requirements change, having them close together reduces unnecessary cognitive load accrued by flipping back and forth between files.
One potential downside to putting your queries in your components is that now, if you ever wanted to switch away from GraphQL, your components aren't pure— they're coupled to GraphQL. If you wanted to switch transport-layer technologies, you'd have to refactor every component.
Another potential downside is that to test these components, you'd need to make sure they're wrapped in a mocked Apollo Client provider.
My recommendation is to couple the queries to the components anyways. What you gain in an incredible developer experience is, in my opinion, worth the risk of going fully in with GraphQL and deciding you want to change later down the road.
Note on query performance: It's ok to have lots of queries for super-specific chunks of data like shown above. Using Apollo Client, Apollo handles that complicated logic of checking whether the data is cached already, and if not — it makes a request to get it.
What to test in presentation components
Unit testing implementation details is typically fruitless — especially for volatile things. It doesn't do us much good testing to see if a button is blue or green. Instead, when testing presentation components, we want to test against UI logic.
To demonstrate what I mean, here's a bland, basic presentation component.
export const Todo = (props) => (
<div className="todo">
<div class="todo-text">{props.text}</div>
<button onClick={props.onDeleteTodo}>Delete</button>
</div>
)
There's no UI logic involved here. It merely takes in props, hooks up callbacks, and renders some HTML.
Here's another example of the same component, but this time, as a class-based component with UI logic.
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'
import TodoTextInput from './TodoTextInput'
type Props = any;
export default class Todo extends Component<Props, Props> {
static propTypes = {
todo: PropTypes.object.isRequired,
editTodo: PropTypes.func.isRequired,
deleteTodo: PropTypes.func.isRequired,
completeTodo: PropTypes.func.isRequired
}
state = {
editing: false
}
handleDoubleClick = () => {
this.setState({ editing: true })
}
handleSave = (id: number, text: string) => {
if (text.length === 0) {
this.props.deleteTodo(id)
} else {
this.props.editTodo(id, text)
}
this.setState({ editing: false })
}
render() {
const { todo, completeTodo, deleteTodo } = this.props
return this.state.editing ? (
<TodoTextInput
text={todo.text}
editing={this.state.editing}
onSave={(text: string) => this.handleSave(todo.id, text)} />
) : (
<div className="view">
<input
className="toggle"
type="checkbox"
checked={todo.completed}
onChange={() => completeTodo(todo.id)} />
<label onDoubleClick={this.handleDoubleClick}>
{todo.text}
</label>
<button
className="destroy"
onClick={() => deleteTodo(todo.id)} />
</div>
)
}
}
UI logic
View behavior & local component state
The main difference between the two previously shown Todo
components is that the second Todo
component contained UI logic where the first did not.
UI logic is view behavior
"If you're logged in, show this — otherwise, show this."
"If you're this type of user, show this — otherwise, show this."
"Depending on which page you're on in the signup process, show the correct form".
A component has UI logic when it exudes behavior. Conditionals that determine what to show, or when certain user events get called over others are a form of view behavior (UI logic).
Here's a conditional example from the previous code sample determining what to show.
return this.state.editing ? (
<TodoTextInput
text={todo.text}
editing={this.state.editing}
onSave={(text: string) => this.handleSave(todo.id, text)} />
) : (
<div className="view">
<input
className="toggle"
type="checkbox"
checked={todo.completed}
onChange={() => completeTodo(todo.id)} />
<label onDoubleClick={this.handleDoubleClick}>
{todo.text}
</label>
<button
className="destroy"
onClick={() => deleteTodo(todo.id)} />
</div>
)
Here's a conditional determining which user event to create.
handleSave = (id: number, text: string) => {
if (text.length === 0) {
this.props.deleteTodo(id)
} else {
this.props.editTodo(id, text)
}
this.setState({ editing: false })
}
Component / local state
This is where the first type of state we might encounter: local (component)
state.
In Jed Watson's talk from GraphQL Summit 2019 titled, "A Treatise on State", he describes five different types of state when building web apps: local (component)
, shared (global)
, remote (global)
, meta
, and router
.
-
Explanations of the five types of state
local (component)
: State that belongs to a single component. Can also be thought about as UI state. UI state can be extracted from a presentation component into a React hook. Note: we're about to do this.shared (global)
: As soon as some state belongs to more than one component, it's shared global state. Components shouldn't need to know about each other (a header shouldn't need to know about a todo).remote (global)
: The state that exists behind APIs in services. When we makequeries
for remote state, we hold onto a local copy of it accessible from a global scope.meta
: Meta state refers to state about state. The best example of this is theloading
async states that tell us the progress of our network requests.- and
router
state: The current URL of the browser.
This state, local (component)
state, belongs to a single component. You can call this UI state. It's meant to hold onto data that helps a single component do its job.
To better see what it looks like, let's extract all UI state from this class-based component and refactor to a functional component and a React hook.
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'
import TodoTextInput from './TodoTextInput'
import { useState } from 'react'
/**
* Decompose the UI logic from the presentational component
* and store it in a React hook.
*
* All data and operations in this hook are UI logic for the
* component - we've just separated concerns, that's all.
*/
function useTodoComponent (actions) {
// "editing" is a form of local (component) state
const [editing, setEditing] = useState(false);
const handleSave = (id: number, text: string) => {
if (text.length === 0) {
actions.deleteTodo(id)
} else {
actions.editTodo(id, text)
}
setEditing(true);
}
const handleDoubleClick = () => {
setEditing(true);
}
return {
models: { editing },
operations: { handleSave, handleDoubleClick }
}
}
/**
* This component relies on some local state, but none of
* it lives within the component, which is purely
* presentational.
*/
export function Todo (props) {
const { todo, actions } = props;
// Grab our local (component) state and access to other UI logic
const { models, operations } = useTodoComponent(actions);
// Conditional UI logic
return models.editing ? (
<TodoTextInput
text={todo.text}
editing={models.editing}
onSave={(text: string) => operations.handleSave(todo.id, text)} />
) : (
<div className="view">
<input
className="toggle"
type="checkbox"
checked={todo.completed}
onChange={() => actions.completeTodo(todo.id)} />
<label onDoubleClick={operations.handleDoubleClick}>
{todo.text}
</label>
<button
className="destroy"
onClick={() => actions.deleteTodo(todo.id)} />
</div>
)
}
UI logic is what we actually try to test within components
Since UI logic is behavior, this is actually what we want to test against in our integration tests. The behavior. You could write unit tests as well, but it might be trivial if component logic is straightforward. It could be more worthwhile and give you more confidence that the feature is working correctly to integration test both the component and the UI logic together.
Container/controller
The glue layer (pages)
Traditionally, the responsibilities of a container component were to:
- Consume user events & pass them to the model
- Subscribe to data changes (reactivity) and keep the view updated
This isn't new. The definition of a controller/presenter, all the way back from the Model-View-Presenter pattern, made this distinction.
Do we really need container components?
In 2019, with the advent of React hooks, Dan said we don't.
The main reason I found [container components] useful was because it let me separate complex stateful logic from other aspects of the component. Hooks let me do the same thing without an arbitrary division.
Here are my thoughts.
I fully agree that complex stateful logic shouldn't live within presentation components. When we do that, we don't get the ability to reuse logic across different components.
Now, as for stateful logic in container components? I don't believe it ever should have been in 'em.
Previously, React developers were advised to put data and behavior in container components and write code that determined "how things work". That breaks the rules of what was said to be the responsibility of a container/presenter.
Just because we know to put stateful data and behavior in React Hooks, it doesn't mean we removed the problems a container component solves.
We still need to configure reactivity, sometimes using Redux, sometimes using Apollo Client or something else, and we still need some construct to act as the glue, knowing which components to load up for the features we enable on a page.
Container components are pages
In the following React Router example, we have three main pages: home, about, and dashboard.
export default function App () {
return (
<Router>
<Switch>
<Route exact path="/">
<Home />
</Route>
<Route path="/about">
<About />
</Route>
<Route path="/dashboard">
<Dashboard />
</Route>
</Switch>
</Router>
);
}
Each page:
- is responsible for enable a variable number of features (remember, a feature is a
command
orquery
) - has a variable number of presentational components within it, and
- knows about
shared
reactive state, and sometimes connects it to presentational components that need it
Container components are the top-level modules that turn on all the features for a particular page. In Gatsby.js, we call them Page components. Since all client architectures naturally evolve from this Model-View-Presenter pattern, it's unlikely we'll get rid of the presenter (container) entirely.
To demonstrate my point, here's a container component in a React hooks world. It might not look like much, but notice that it fulfills the two responsibilities of a container component.
import React from 'react'
import MainSection from '../components/MainSection'
import { useQuery } from '@apollo/client'
import { VisiblityFilter } from '../models/VisibilityFilter'
import { Todos } from '../models/Todos'
import { GET_ALL_TODOS } from '../operations/queries/getAllTodos'
import { GET_VISIBILITY_FILTER } from '../operations/queries/getVisibilityFilter'
import { todoMutations } from '../operations/mutations'
const todosAPI = new TodosAPI();
export default function Home () {
// Shared (global) or remote (global) state.
const { operations, models } = useTodos(todosAPI);
const {
completeAllTodos,
setVisibilityFilter,
clearCompletedTodos
} = operations;
return (
<Layout>
<MainSection
// Pass data to components
activeVisibilityFilter={visibilityFilter}
todosCount={models.todos.length}
completedCount={models.todos.filter(t => t.completed).length}
// Delegate operations to the model
actions={{
completeAllTodos,
setVisibilityFilter,
clearCompletedTodos
}}
/>
<ReportSection
// Pass data to components
todos={models.todos}
// Delegate operations to the model
actions={{
completeAllTodos,
setVisibilityFilter,
clearCompletedTodos
}}
/>
</Layout>
);
};
Something is responsible for knowing how to connect to a reactive model, and knowing what to do with events that come from presentation components. That's a container.
Of course, you could call everything a component, but then the explicit communication and delineation of responsibilities we're fighting for is lost.
Container components contain no functionality
The container component is pretty bare. That's a good thing. They're not supposed to contain any functionality. They're not worthy of unit testing. They're just meant to stitch things together. However, if you want to do an integration test all features of a page, just load up the container component and have at 'er.
Interaction layer
Model behavior
We're finally in the most challenging part of a client-side architecture: the model.
The first layer of the model, which is what gets called from the container component, is the interaction layer.
The interaction layer is the behavior of the model
When you click submit to "add a todo", do you jump straight to the GraphQL mutation
right away? Do you perform any validation logic? Are there any rules to enforce?
A lot of times, there aren't any rules. Sometimes we can't be bothered and we leave validation logic as something the server handles. This is particularly common on simple dashboard apps. These apps have pretty much no rules to enforce, so an interaction layer doesn't exist.
It goes controller → network request.
Or as we've been doing for a long time, presentation component → network request.
When there is policy to enforce, it's time to think about carving out an interaction layer.
The interaction layer is the decision-making layer
Application (or interaction) logic is the logic that makes a decision as to what happens next.
Let's say you have a command
called createTodoIfNotExists
. Whatever construct is responsible for the interaction layer contains the code that helps you decide, "should we follow through with this"?
Here's a Redux Thunk example, where sometimes, we need to reach into some form of global
state (maybe cached in a store) to make a decision.
// Interaction example
export function createTodoIfNotExists (text: string) {
return (dispatch, getState) => {
const { todos } = getState();
const alreadyExists = todos.find((t) => t === text);
if (alreadyExists) {
return;
}
...
// Validate
// Request
}
}
Alternatively, here's a React Hooks & Apollo Client example.
function useTodos (todos) {
const createTodoIfNotExists = (text: string) => {
const alreadyExists = todos.find((t) => t === text);
if (alreadyExists) {
return;
}
...
// Validate
// Request
}
return { createTodoIfNotExists }
}
// Container
function Main () {
const { data: todos } = useQuery(GET_ALL_TODOS);
const { createTodoIfNotExists } = useTodos(todos);
...
}
It contains your application's operations
Some refer to this layer as app logic, which works as well because these are all of the operations of your app. The interaction layer contains the discrete set of commands
and queries
that your users will carry out. These are the use cases.
Having great visibility into these use cases enables us to get pretty structured with our integration testing as well. We can functionally test every use case with edge cases using Given-When-Then style tests.
For example:
- Given no todos exist, when I perform
CreateTodo
, then I should see one todo. - Given I have 3 completed todos and 1 uncompleted one, when I perform
CompleteAllTodos
, then I should have 4 completed todos.
If you're familiar with Domain-Driven Design concepts, this is the Application Service equivalent.
Shared behavior
This behavior is written to be used by any component. It contains the rules for how shared state is allowed to change.
At this level, we're often handling concerns like auth
, logging
, or even more domain-specific things like todos
, users
, calendar
, or even chess
.
Consider an interaction-layer React hook that contained all your chess game logic.
function useChess (todosAPI: ITodosAPI) {
...
return {
operations: { makeMove, isValidMove, ... },
models: { board, players, currentTurn }
}
}
Read "Domain-Driven GraphQL Schema Design" for the principles and practices for how to use event Storming to discover the subdomains within your app.
Other ways to implement the model
Though most React developers will be comfortable writing their application/interaction layer logic using something like React Hooks, there's tons of other ways to implement the model.
- If you like to think of your model as a state machine, the xState library does this exceptionally well and provides capabilities for you to plug your model instance into a React hook.
- For those who want to try to model their interaction layer using plain vanilla JavaScript, the pojo-observer library takes advantage of the fact that every client-app is an implementation of the observer pattern. Separating your model code from React hooks, it also provides a way to notify React that the model changed so a re-render is necessary.
Someone once asked me if it's possible to do DDD in the front-end. Initially, I said no, but after sometime thinking about it, it totally is. While the true high-level policy will always live on the backend, the interaction layer is comparable to the Application and possibly Domain layer in DDD.
There are usually several layers
Most of the time, your app will have several of these application / interaction layers.
Here are some more examples of interaction layers that are commonly built out.
-
Examples of other interaction layers
- Auth layer — Extremely common. Check out the useAuth library which implements Auth0 authentication and authorization as a React hook.
- Logging — Sometimes it's important to. Luckily, there are many tools out there that can do this for you, but if you needed to build one yourself, it would exist as an entirely separate layer within your model.
- Real-time subscriptions — Let's say you're subscribed to a stream of data. When a chunk comes in, you need to process it, and perhaps act on a
switch
statement to figure out if you should invoke acommand
. Keep your code clean by delegating this responsibility to a layer. - Complex rendering logic — I once worked on a project that built out really complex call flows for call centers using Angular and D3. Hundreds of different node types could be dragged and dropped onto a surface. When dropped, the way they connected to each other and how they could be used depended on the rendering and application logic, each decoupled from each other.
- Metadata layer — Imagine building a multiplayer video game where new prizes and weapons come out every week. How can we prevent hard-coding weapons and prizes?
If you're curious about what a large-scale version of this looks like, check out Twilio's video-app example built with React hooks and context for global state.
🚡 Networking & data fetching (infrastructure)
Performing API calls and reporting metadata state
The responsibilities of a networking and data fetching layer are to:
- Know where the backend service(s) are
- Formulate responses
- Marshal response data or errors
- Report async statuses (isLoading)
Reporting metadata state
Jed Watson describes the async states that tell you about the status of a network request as meta state — state about state.
For example, in Apollo Client, the loading
variable we deconstruct from the query response is a form of meta state.
const { data, loading, error } = useQuery(GET_ALL_TODOS);
With Apollo Client, that's handled for us. Though if we were to use a more barebones approach, like Axios and Redux, we'd have to write this signaling code ourselves within a Thunk.
export function createTodoIfNotExists (text: string) {
return async (dispatch, getState) => {
const { todos } = getState();
const alreadyExists = todos.find((t) => t === text);
if (alreadyExists) {
return;
}
// Signaling start
dispatch({ type: actions.CREATING_TODO })
try {
const result = await todoAPI.create(...)
// Signaling success
dispatch({ type: actions.CREATING_TODO_SUCCESS, todo: result.data.todo })
} catch (err) {
// Signaling Failure
dispatch({ type: actions.CREATING_TODO_FAILURE, error: err })
}
}
}
Note: The code example above is a demonstration of doing a little too much. Recall that a Redux Thunk is an interaction layer concern? That means it should only be responsible for the decision-making logic, and no signalling logic, since request signalling is a concern of the networking & data-fetching layer. It can be hard to establish these concrete boundaries sometimes. Especially if the library or framework wasn't designed with separation of concerns in mind.
🗄️ State management & storage (infrastructure)
Storage, updating data, reactivity
A state management library has three responsibilities:
- Storage — Hold onto global state somewhere, usually in a store / client-side cache.
- Updating data — Make changes to the data in the cache.
- Reactivity — Provide a way for view-layer presentation components to subscribe to data, and then re-render when data changes.
State management and networking are often solved together
State management is complex.
Because it's complex, there are libraries out there to make life a little bit easier. Two of those libraries, Apollo Client and react-query, actually handle the networking part for you.
It can be preferable to choose a library instead of building out the state management machinery and networking layer manually.
Apollo Client handles both the state management and data fetching concerns.
Shared global state
Two types of state exist at this layer. They are:
remote (global)
state — The state that exists behind APIs in services. When we makequeries
for remote state, we hold onto a local copy of it accessible from a global scope.shared (global)
: We said earlier, "as soon as some state belongs to more than one component, it's shared global state". And you'll know you need this when two components that rely on the same state don't need to know about each other. To be clear, this type of state can be live in the interaction layer (via hooks and context, for example). Though sometimes, when working withremote(global) state
, it's preferable to have something act as a single source of truth, especially if you need to mix remote and local state.
Mixture of remote and local state
We often cache remote state in a client-side cache or store. Since we do that, it's reasonable to try to use the store as a single source of truth. Often, we'd like to add some client-only local variables or pieces of state to the store as well.
Here's a Redux example of adding an isSelected
attribute to each of the todos
before merging to the store.
switch (action.type) {
...
case actions.GET_TODOS_SUCCESS:
return {
...state,
// Add some local state to the remote state before merging it
// to the store
todos: action.todos.map((t) => { ...t, isSelected: false })
}
}
And in Apollo Client 3, here's the equivalent with cache policies and reactive variables.
import { InMemoryCache } from "@apollo/client";
export const cache: InMemoryCache = new InMemoryCache({
typePolicies: {
Todo: {
fields: {
isSelected: {
read (value, opts) {
const todoId = opts.readField('id');
const isSelected = !!currentSelectedTodoIds()
.find((id) => id === todoId)
return isSelected;
}
}
}
}
}
});
export const currentSelectedTodoIds = cache.makeVar<number[]>([]);
We can configure a way to request remote
state and client-only shared
local state in the same query.
export const GET_ALL_TODOS = gql`
query GetAllTodos {
todos {
id
text
completed
isSelected @client
}
}
`
Storage facades
Most of the time we don't provide direct access to whats stored within the store. Usually, there's some facade, an API, that sits in-front of the data and provides ways for us to interact with it.
In Redux, this is dispatch
(for updates) and connect (for reactivity).
In Apollo Client, this is useMutation
(for updates) and useQuery
(for reactivity).
Even SQL is a form of a storage facade. It's a powerful pattern.
Stay in touch!
Join 20000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. 🖖
View more in Client-Side Architecture