Client-Side Architecture Basics (Guide) - Khalil Stemmler
Client-Side Architecture Basics (Guide) - Khalil Stemmler
[Guide]
Client-Side Architecture
Though the tools we use to build client-side web apps have changed
substantially over the years, the fundamental principles behind
designing robust software have remained relatively the same. In this
guide, we go back to basics and discuss a better way to think about
the front-end architecture using modern tools like React, Redux,
xState, and Apollo Client.
khalilstemmler.com
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
Back then, class-based components and Redux were the coolest kids on
the block. This was my rst time prepping up to work on a real-world
React project, so I bought the best courses I could nd on the topics and
dove in.
I think it's incredible that we question the way we do things. But my vast
gap in knowledge of client-side architecture left me always nding it
necessary to play catch up to refactor to the new approaches.
🐬
At each f kin' stop, there's a new choice to make: a
new chance to be wrong.
It's the result of my research using rst principles on how to design and
khalilstemmler.com
develop robust,
Introduction exible,
Shared language testable,
Client-side needs Inand maintainable
uential design principles client-side
Presentation components UI logic
applications.
Container/controller components Interaction layer Networking & data fetching Conclusion
During my experience working on client-side apps of varying sizes, I've
realized that some serious upfront design on the architecture can have a
signi cant impact on the quality of the code for the duration of its life.
While we're primarily focused on React, because it's the most popular
library with the least structure, the principles are transferrable to any
con guration of view-layer library or framework.
Programming tends to seem more like a trade than a science. Each tool,
be it a state management library, API, or a transport-layer technology, is
best suited to solve a particular set of problems. As a developer and a
tradesperson, it's good to know how the tools in our toolbox are best
used.
At the end of this guide, you'll learn a standard for web development.
You'll have a clear understanding of the discrete layers of concerns in a
client-side app: from the view layer to various forms of state
management, and how to handle interaction (app) logic.
khalilstemmler.com
Prerequisites
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
Model-View controller
MVC says that we should split our application into model, view, and
controller layers. This is so each layer can focus on their own respective
responsibilities.
and the controller turns user events into changes to the model
khalilstemmler.com
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
This works! And we like it. At least, we must — it's one of the rst
architectural patterns we teach to new developers learning how to build
full-stack apps.
khalilstemmler.com
Model-View presenter
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
Model-View-Presenter is the architectural pattern typically used within client applications. It's a derivation of
the MVC pattern.
Those user events get turned into updates or changes to the model.
When the model changes, the view is updated with the new data.
khalilstemmler.com
It's heartening to realize that every client app uses some form of the
Introduction
Shared language Client-side needs In uential design principles Presentation components UI logic
model-view presenter pattern.
Container/controller components Interaction layer Networking & data fetching Conclusion
MVC & MVP are too generic
MVC and MVP are great starters. They give you a good enough
understanding of the communication pathways from a 5000-ft view.
Unfortunately, they both suffer from the same problem: being too
generic.
As a result, developers don't know which tools are responsible for which
tasks.
In MVC and MVP, the model is ambiguous. This makes matching the
correct tool up to the task feel like a puzzle.
In the real world, the model portion in most client-side web apps does a
khalilstemmler.com
lot.
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
React hooks
Redux
Context API
Apollo Client
xState
react-query
khalilstemmler.com
Communicate which concerns are addressed by which tool
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
Avoid code from concerns creeping too profoundly into another
Container/controller components Interaction layer Networking & data fetching Conclusion
If we can, as a community, communicate a shared understanding of the
concerns that make up the model (and the other parts), I think we can
more easily answer questions like this:
Let's not discredit the software design and architecture research done
over the last 30 years.
khalilstemmler.com
Let's lookShared
Introduction
at backend
language
development.
Client-side needs In uential design principles Presentation components UI logic
Initially, with MVC on the server, we thought the model could be services,
ORMs, or even the database itself. Each of these are part of the model,
but they're not the entire model.
khalilstemmler.com
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
The clean architecture (which has many similar variants — see layered,
hexagonal architecture, or ports & adapters) provides speci cs as to
what the M in the model is.
The middle layers (domain and application) are the purest. It's the code
that we, the developers, have to write from scratch. And since our app
doesn't do much unless we can hook it up to the real-world using things
like web servers, databases, APIs, and caches, the adapter layer provides
a exible way to integrate those infrastructural dependencies into our
khalilstemmler.com
app, while keeping them distanced from our domain and app layer code.
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
A layered architecture like this comes at the cost of being more complex
than a simple single-tiered one, but let's be honest — sometimes we have
to solve some damn hard problems.
It keeps concerns separate and enables you to keep your app and
domain layer code unit testable.
Quick question.
khalilstemmler.com
Let's back
Introduction
up a bit. Client-side needs
Shared language In uential design principles Presentation components UI logic
What are we really looking for when we talk about architecture on the
client-side? Why does any of this matter? Why don't we just write all of
our code in a single le (actually, some of us do write single le
components)? Is architecture about le organization, or is it about
something more?
Testability
I've noticed that an alarming amount of developers don't write tests for
their front-end code.
If you understand the app you're building and the complexity of it, you
khalilstemmler.com
can kind-of gauge this upfront.
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
Flexibility
It's not so often that we need to switch from REST to GraphQL or swap
out APIs, but there are a select few cases that we should enable
exibility for.
Maintainability
I believe that developers who care not only about getting the job done
but also getting it done right will push through learning curves.
You'll notice that each principle, in some way, is about enforcing some
structural constraints as to what can be done, and how things are
organized.
Command-Query Separation
Separation of Concerns
khalilstemmler.com
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
The primary bene t of this pattern is that it makes code easier to reason
about. Ultimately, it urges us to carve out two code paths: one for reads,
and one for writes.
Commands
Queries
Queries are operations that return data and perform no side-effects. Like
these, for example:
Simpli es the code paths — this is what React hooks does with
the accessor/mutator API of useState , and what GraphQL does
with queries and mutations .
Separation of Concerns
Well, the view passes off the event to a container. That could connect the
user event to a method from a React Hook or a Redux thunk. From there,
we might want to run some logic, decide if we should invoke a network
khalilstemmler.com
request, Shared
update
Introduction
the state
language
stored locally,
Client-side needs
then somehow
In uential design principles
notify the UI that
Presentation components UI logic
CQSkhalilstemmler.com
said that every feature is an operation. It also said that every
operationShared
Introductionis either
language a Client-side
command or
needs Inquery .
uential design principles Presentation components UI logic
I like to think of features as vertical slices that cut through the stack.
khalilstemmler.com
Need to change
Introduction
the way
Shared language
the loginIncomponent
Client-side needs
looks?Presentation
uential design principles
No problem, you're
components UI logic
going toContainer/controller
add some componentsstyles to the presentational
Interaction layer Networkingcomponent
& data fetching in the
Conclusion
presentation layer from the Login feature.
Need to change what happens a when todo open for longer than 30 days
was just completed? Want to throw confetti on the screen and say how
proud of the user you are? Gotcha. Add some logic to the xState model
from the interaction layer for the Complete Todo feature.
khalilstemmler.com
Application logic: Hooks + xState
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
State management:
Container/controller Apollo
components Client
Interaction layer (global state)
Networking & data fetching Conclusion
Data fetching: Apollo Client
I rst heard of the term vertical slices from Jimmy Bogard. Thinking of
features this way reduces the amount of time it takes for developers to
gure out where to add or change code.
This is where developers get stuck, guring out what the layers of the
stack are, and which tools can be used at each layer of the stack.
khalilstemmler.com
Why does it matter?
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
Layers
khalilstemmler.com
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
Presentation components
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
khalilstemmler.com
🏀.
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
khalilstemmler.com
const CARD_DESCRIPTION_QUERY = gql`
query CardDescription($cardId: ID!) {
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
card(id: $cardId) {
Container/controller components Interaction layer Networking & data fetching Conclusion
description
}
}
`;
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.
components?
/components/Todo.tsx
/components/Todo.tsx
state = {
editing: false
}
handleDoubleClick = () => {
this.setState({ editing: true })
}
render() {
const { todo, completeTodo, deleteTodo } = this.props
khalilstemmler.com
return this.state.editing ? (
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
<TodoTextInput
Container/controller
text={todocomponents
.text} Interaction layer Networking & data fetching Conclusion
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
khalilstemmler.com
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
"If you're this type of user, show this — otherwise, show this."
return this.state.editing ? (
<TodoTextInput
text={todo.text}
editing={this.state.editing}
onSave={(text: string) => this.handleSave(todo.id, text)} />
) : (
<div className="view">
<input
khalilstemmler.com
className="toggle"
Introduction
type="checkbox"
typelanguage
Shared ="checkbox "
Client-side needs In uential design principles Presentation components UI logic
checked={todo.completed}
Container/controller components Interaction layer Networking & data fetching Conclusion
onChange={() => completeTodo(todo.id)} />
<label onDoubleClick={this.handleDoubleClick}>
{todo.text}
</label>
<button
className="destroy"
onClick={() => deleteTodo(todo.id)} />
</div>
)
In Jed Watson's talk from GraphQL Summit 2019 titled, "A Treatise on
State", he describes ve different types of state when building web apps:
local (component) , shared (global) , remote (global) , meta , and
router .
khalilstemmler.com
Introduction
local (component)
Shared language
: State
Client-side needs
that belongs to Presentation
In uential design principles
a singlecomponents UI logic
component. Can also be thought
Container/controller components
Interaction layer about
Networking asfetching
& data UI state. UI
Conclusion
state can be extracted from a presentation component
into a React hook. Note: we're about to do this.
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.
/components/Todo.tsx
/** Container/controller components Interaction layer Networking & data fetching Conclusion
* 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.
*/
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.
*/
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
// Conditional UI logic
Container/controller components Interaction layer Networking & data fetching Conclusion
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>
)
}
Container/controller
This isn't new. The de nition of a controller/presenter, all the way back
from the Model-View-Presenter pattern, made this distinction.
I fully agree that complex stateful logic shouldn't live within presentation
khalilstemmler.com
components. When we do that, we don't get the ability to reuse logic
across different
Introduction components.
Shared language Client-side needs In uential design principles Presentation components UI logic
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.
In the following React Router example, we have three main pages: home,
about, and dashboard.
src/App.js
Introduction Shared<language
Dashboard />
Client-side needs In uential design principles Presentation components UI logic
</Route>
Container/controller components Interaction layer Networking & data fetching Conclusion
</Switch>
</Router>
);
}
Each page:
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.
/modules/home/Home.container.tsx
return (
<Layout>
<MainSection
// Pass data to components
activeVisibilityFilter={visibilityFilter}
todosCount={models.todos.length}
completedCount={models.todos.filter(t => t.completed).length}
Of course, you could call everything a component, but then the explicit
communication and delineation of responsibilities we're ghting for is
lost.
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
The rst layer of the model, which is what gets called from the container
component, is the interaction layer.
khalilstemmler.com
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
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?
When there is policy to enforce, it's time to think about carving out an
interaction layer.
/todos/redux/thunks/createTodoIfNotExists.tsx
// Interaction example
export function createTodoIfNotExists (text: string) {
return (dispatch, getState) => {
const { todos } = getState();
if (alreadyExists) {
return;
}
...
// Validate
// Request
}
}
/models/useTodos.tsx
khalilstemmler.com
if (alreadyExists) {
return;
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
}
Container/controller components Interaction layer Networking & data fetching Conclusion
...
// Validate
// Request
}
return { createTodoIfNotExists }
}
// Container
function Main () {
const { data: todos } = useQuery(GET_ALL_TODOS);
const { createTodoIfNotExists } = useTodos(todos);
...
}
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:
Shared behavior
At this level, we're often handling concerns like auth , logging , or even
more domain-speci c things like todos , users , calendar , or even
chess .
/hooks/useChess.tsx
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.
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.
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.
khalilstemmler.com
The responsibilities of a networking and data fetching layer are to:
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
Formulate responses
Jed Watson describes the async states that tell you about the status of a
network request as meta state — state about state.
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.
khalilstemmler.com
Introduction Shared language
export function Client-side needs
createTodoIfNotExists In uential
(text design principles
: string ) { Presentation components UI logic
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 })
}
}
}
khalilstemmler.com
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
Storage, updating data, reactivity
Container/controller components Interaction layer Networking & data fetching Conclusion
A state management library has three responsibilities:
together
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.
khalilstemmler.com
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
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.
return isSelected;
}
}
}
}
}
});
Storage facades
Most of the time we don't provide direct access to whats stored within
store. Usually, there's some facade, an API, that sits in-front of the
the khalilstemmler.com
data andShared
provides
Introduction language
ways for us to interact
Client-side needs
with it.
In uential design principles Presentation components UI logic
Conclusion
But let's look at what we've covered. Zooming out, we started with this:
khalilstemmler.com
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
khalilstemmler.com
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
anymore
The problems we're solving on the client-side are much more complex
than they were 20 years ago. Because of that, the starting point for an
architecture probably can't be MVP.
I honestly think that reading books and learning from the past is one of
khalilstemmler.com
the best ways to avoid future mistakes. Design principles are great. You
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
I'm really excited about these ideas. I've been milling around with this for
a couple of months now but I think it's really important today. If you're a
developer that has been told to use "what works for you", that's still
incredibly good advice. But if you run into any of the pain-points in your
React project like suddenly facing issues adding features, changing
code, and feeling like things have turned to mush, this might help.
Next steps
Dealing with prop drilling: If we have these clear-cut layers, doesn't that
mean we'll have to do a lot of prop drilling?
khalilstemmler.com
Introduction Shared language Client-side needs In uential design principles Presentation components UI logic
Discussion
Container/controller components Interaction layer Networking & data fetching Conclusion
Liked this? Sing it loud and proud .
Share on Twitter
9 Comments
Name
Comment
Submit
Now you can just have really small components that only care about showing something and
then "higher" components that de ne the layout and only pass its children. That way you can
pass a lot of props without making the layout ones caring about those, they only care about
the children they have inside and how to style them properly. For example, like a reusable
table, you have a component for the rows, that only cares about how many children you send
in as columns, they could all end up being different dumb components you created for lets
say, main info, and generic info, main info in the case of the project Im on, has a button that
triggers an Apollo mutation to start following or to stop following that entry, and a text with
the main ID. Said button is another dumb component that just passes the type of graphql
model that called it along with the ID, so the mutation is executed elsewhere. Before that that
logic was shared across several screens, it was not sane whenever changes where
implemented, so refactoring it that way along with composition made it much more
manageable and now I only have to care about a single le for it.
khalilstemmler.com
Still from
Introduction this article
Shared it seems
language I can needs
Client-side even goIneven further,
uential sadly Im still
design principles not so components
Presentation good with hooks
UI logic
so I end up using a class based component
Container/controller components
for the main
Interaction layer
screens/pages thatConclusion
Networking & data fetching
are in charge of
organizing and styling the layout to show, and all the components they call for it are just
functional components.
A question though, when looking through state management I came across a few articles
from Dan saying to basically, if you can keep the state in the component, just do that instead
of trying to go plus ultra and sending everything to redux. In this case, if you have a single
piece of logic that you know doesn't need to be shared across several pages/screens etc.
would implementing the state locally would go against this architecture?
Li 24 days ago
Great post! Thank you!
Stay in touch!
Khalil Stemmler,
Developer Advocate @ Apollo GraphQL ⚡
Khalil is a software developer, writer, and musician. He frequently publishes
articles about Domain-Driven Design, software design and Advanced TypeScript
& Node.js best practices for large-scale applications.