My grudge against the front-end community is that we’re too focused on improving low-level APIs and building new tools instead of thinking about architecture and patterns. We’ve iterated on state management a thousand times, but we’re yet to translate important programming concepts to the UI.
I will try to explain how we can apply the concepts of hexagonal architecture in React.
This pattern has greatly influenced how I write software in the last two years, and I’ve found it applicable to various technologies and languages. I’ve used it successfully with Go and Node so far, and I’ve been thinking of a good way to apply it in the front-end without additional complexity or tedious boilerplate.
Hexagonal Architecture
The main idea in hexagonal architecture, clean architecture, and all other similar paradigms is the concept of decoupling domain from infrastructure logic. In other words, putting boundaries between the functionality specific to the business.
I have an entire article about it here.
In the back-end this would mean abstracting away the database queries, HTTP logic, and external requests. Hexagonal architecture makes you think about your business logic as the core of your application with everything else attached to it.
Hexagonal Architecture in React
So when it comes to front-end development, we don’t have database or cache queries to modify, and we don’t handle requests and responses the way we do in a REST API. The only thing we have from the list above are the external requests.
function PostPage({ postId }) {
const user = useUser()
const [post, setPost] = useState(null)
const [error, setError] = useState(null)
const getPost = () => {
axios
.get(`http://example.com/api/v1/post/${postId}`)
.then(setPost)
.catch(setError)
}
const bookmarkPost = () => {
axios.post(`http://example.com/api/v1/bookmark`, {
data: { post_id: postId, user_id: userId },
})
}
const reactToPost = (reaction) => {
axios.post(`http://example.com/api/v1/reaction`, {
data: { post_id: postId, user_id: userId, reaction },
})
}
useEffect(() => {
getPost()
}, [])
// Calculate total number of reactions for each type to display them
const reactions = post?.user_reactions.reduce((acc, curr) => {
if (!acc[curr.type]) {
acc[curr.type] = 0
}
acc[curr.type] += 1
return acc
}, {})
if (!post) {
return <div>Loading</div>
}
if (error) {
return <div>Error: {error.message} </div>
}
return (
<div>
<h1>{post?.title}</h1>
<div>
<p>{post?.text}</p>
</div>
<aside>
<h3>Reactions</h3>
<ul>
{Object.entries(reactions).map(([type, count]) => (
<li>
<a onClick={() => reactToPost(type)}>
{count} <ReactionEmoji type={type} />
</a>
</li>
))}
</ul>
</aside>
<button onClick={bookmarkPost}>Bookmark</button>
</div>
)
}
What most front-end engineers would improve after a single glance at the component is the way the HTTP calls are made. It’s fairly obvious that this logic has no place in it so the functions can be extracted in a service.
// ...
const getPost = () => {
client.getPost(postId).then(setPost).catch(setError)
}
const bookmarkPost = () => {
client.bookmarkPost(postId, userId)
}
const reactToPost = (reaction) => {
client.storePostReaction(postId, userId, reaction)
}
// ...
We’ve hidden the details of the API calls and the snake case of their parameters, leaving the component to call simple functions and keep the accepted naming conventions.
But while we focus on getting HTTP calls out of the way, we forget that the browser falls in the infrastructure layer too. It’s something external that our code interfaces with in order to paint elements on the screen.
And we leave the business logic in it.
React is a library that helps us communicate with the browser. But in our attempt to translate patterns from back-end development, we forget this detail. And we leave our domain functionality tightly coupled with the functionality that renders the result.
In its essence, a React component should only render elements based on data and trigger events. Yet, most projects I’ve worked on are the exact opposite.
The components become the integration point of the domain logic and visualization logic, and they grow in both size and complexity. The tighter the coupling, the harder it will be to test independently. But most importantly, we increase the blast radius of every change we make.
A modification of the component’s returned markup may affect the domain logic and vice versa.
We want to apply the principles of hexagonal architecture and find a way to move as much of our domain functionality as we can outside of the component.
function PostPage({ postId }) {
const { post, error, bookmarkPost, reactToPost } = usePost(postId)
if (!post) {
return null
}
if (error) {
return <div>Error: {error.message}</div>
}
return (
<div>
<h1>{post.title}</h1>
<div>
<p>{post.text}</p>
</div>
<aside>
<h3>Reactions</h3>
<ul>
{Object.entries(post.reactions).map(([type, count]) => (
<a onClick={() => reactToPost(type)}>
{count} <ReactionEmoji type={type} />
</a>
))}
</ul>
</aside>
<button onClick={bookmarkPost}>Bookmark</button>
</div>
)
}
We have effectively extracted the whole setup part of the component into a custom hook that returns everything it needs - both data and functions.
const { post, error, status, bookmarkPost } = usePost(postId)
We’ve hidden the detail that the reactions are calculated in the front-end by attaching them to the post
object.
Now instead of mocking multiple libraries and modules or intercepting requests to test the component, we can mock one hook and test our component’s different variants. Testing the business logic in the custom hook gets much easier as well since we won’t have to deal with React specifics.
We’ve hidden the fact that bookmarking and reacting to a post requires the current user’s id since it’s only used in the hook. This leaves PostPage
focused only on rendering and calling functions.
So far, we’ve done little more than create a common abstraction over multiple functions - a well-known technique.
But we can even go one step further and use our custom hooks only as a “port” between the business logic and the components. We can apply the concept of ports and adapters from hexagonal architecture to decouple the implementation further.
function usePost(postId) {
const user = useUser()
const [post, setPost] = useState(null)
const [error, setError] = useState(null)
const getPost = () => {
client.getPost(postId).then(setPost).catch(setError)
}
const bookmarkPost = () => {
client.bookmarkPost(postId, userId)
}
const reactToPost = (reaction) => {
client.storePostReaction(postId, userId, reaction)
}
useEffect(() => {
getPost()
}, [])
return {
post: mapToDomainObject(post),
error,
bookmarkPost,
reactToPost,
}
}
Now the calculation of the reactions is done outside the hook, in a function that is unaware of the environment in which it is executed. This is how we want to write our domain logic - decoupled from frameworks.
Creating this hook may seem like too much effort to extract a single reduce
call, but the operations we do on our data are often more complex than that, and I find it worth detangling from the component. Also, on our way to this design, we’ve gained other valuable benefits.
We have the freedom to implement our custom logic following any paradigm we like. The react-query library, for example, is written in an object-oriented way, even though that’s not a popular paradigm in the front-end world.
But because their implementation is so decoupled, this doesn’t affect the API that you interact with as a user. You can read more about it here.
So you can write your data fetching and parsing logic like so if you want to.
function usePost(postId) {
const mapper = new PostMapper(post)
return {
post: mapper.toDomainObject(),
// ...
}
}
But these details remain hidden from your React component which you can still implement in a functional style.
Criticism
A common criticism of the idea of using hooks as ports is that they are only meant to encapsulate repeated logic. Many engineers see them only as a mechanism to fight duplication.
But to me, they offer an abstraction that is native to react. They look natural when you see them in a component, so we keep to the idiomatic practices. We can use them to group multiple other hooks under a common umbrella or to hide details the component shouldn’t be aware of.
Think of it as extracting a logical part of a function instead.
Another concern is that the custom hooks end up returning too many things. Hiding the complexity by jamming it together in a single function does not sound appealing to many engineers.
But this complexity we just extracted was already present in another function (the component) which had other responsibilities too. Surely we’ve made some progress in terms of design with that.
Even if we just put it in a custom hook, we still get a certain degree of decoupling and improve the component’s testability and readability.
More Complex Components
Often, our components hold more functionality besides data fetching and simple parsing. Many of them need to define complex event handlers, which contributes to their over-complication.
My advice would be to, again, extract these in a custom hook.
You can make a strong argument to keep them in the component since they are most likely triggered by an action in the UI. But to me, an event is just a signal. How we react to that event is a matter of business logic.
Let’s look at a more complex component.
function ChatBox() {
const user = useUser()
const socketRef = useRef()
const [messages, setMessages] = useState([])
const [isTyping, setIsTyping] = useState(false)
const [message, setMessage] = useState('')
const setupSocketConnection = () => {
socketRef.current = createSocketConnection(user.uuid)
socketRef.current.on('MESSAGE', (newMessage) => {
setMessages((previousMessages) => [
...previousMessages,
newMessage,
])
})
socketRef.current.on('TYPING', () => {
setIsTyping(true)
})
}
const getMessages = async () => {
try {
const data = await messageClient.getLatestMessages(user.id)
setMessages(data)
} catch (e) {
setMessagesLoading(false)
}
}
const sendMessage = async (message) => {
const newMessage = {
text: message.text,
userId: user.id,
userName: user.name,
}
setMessages((previousMessages) => [
...previousMessages,
newMessage,
])
socketRef.current?.emit('SEND', newMessage)
}
useEffect(() => {
getMessages()
}, [])
useEffect(() => {
setupSocketConnection()
return () => {
if (socketRef.current) {
socketRef.current.disconnect()
}
}
}, [])
return (
<section>
<MessageList messages={messages} />
{isTyping ? <p>User is typing...</p> : null}
<ChatInputField
value={message}
onChange={(e) => setMessage(e.target.value)}
onEnterPress={() => sendMessage(message)}
/>
</section>
)
}
This component gets subscribed to a socket server when it gets mounted, provides messaging functionality, and renders them on the screen. I believe the only responsibility it should have is the last one.
The logic to initiate the socket connection is abstracted away in the createSocketConnection
function, following the best practices around hiding non-business logic. But I don’t think that’s enough.
The types of incoming messages, the subscription details, the format of the messages, and the API calls to load the initial thread - all these details could be removed. Also, the component doesn’t use the user object in the rendered markup, yet it has to have access to it so it can load the messages.
function ChatBox() {
const { message, messages, isTyping, sendMessage, setMessage } =
useChat()
return (
<section>
<MessageList messages={messages} />
{isTyping ? <p>User is typing...</p>}
<ChatInputField
value={message}
onChange={(e) => setMessage(e.target.value)}
onEnterPress={sendMessage}
/>
</section>
)
}
That’s all this component should have access to. It shouldn’t deal with setting up a socket connection or being aware of the internal format of the messages. With this change, it only receives data and renders markup based on it.
Concerns
My biggest concern when I started exploring this idea was whether it would add too much boilerplate. Frameworks in languages like Java are often criticized for how much one has to write to achieve simple things.
I was afraid that I’d be leaning towards practices that the front-end community is trying to stay away from. But after examining the resulting application structure and imports, I’ve managed to convince myself that the trade-off is worth it.
The lines of code overhead is insignificant, and extracting a custom hook to provide all of these values and functions removes incredible amounts of complexity from the component.
A problem may arise when a component has functionality tied to its props, not only other hooks. If it only has to execute a callback function, I would inline it in the markup. But if you have to run parsing or validation logic before using a value from props, we get into a tricky situation.
My first approach would be to define the logic as a function inside of our component’s custom hook and have it accept the props it needs as arguments. An alternative, if the first approach is not applicable, is to pass the props to the custom hook so it can prepare the functions it needs and then use those functions in the component.
With both approaches, you avoid implementing logic inside the component’s body.
Architecture as Strategy
The examples I’ve shown here build on the timeless idea that we should encapsulate functionality that changes at a different pace. Our business logic is constantly evolving, so we don’t want it to impact the code related to databases and transport.
In front-end development, business logic and markup have historically been tied together because they both change so frequently. But I think the levels of complexity in the UI have reached a point in which we have to revisit this idea, and this is how I think we can practically implement it in React.
We can make a really good analogy with chess.
Strategic moves give a tactical advantage. If you apply the strategy to control the middle of the chess board, your pieces will have more movement options later in the game. You will be in an advantageous position.
The same goes for programming.
If you employ the strategy of decoupling your business logic from the rest of your application, you will gain the long-term advantage of being able to modify and replace it independently.