One Hook Per Screen: a simple architecture for scalable React Native apps
Mo Khazali7 min read
“Architecture is the decisions that you wish you could get right early in a project”
Ralph Johnson
When I built my first site, the web felt like a simpler place. I would create an html
file for my markup, a css
file to have all of my styles, and js
files if I had any interactivity or logic. This design choice intentionally defined a clear separation of concerns (SoC).
Fast forward to today, and we use React & JSX to intentionally break this barrier and bring our markup into Javascript. If you want to make it even wilder, throw in a styling framework like tailwind and you’ll have your content, business logic, and presentation layers all in one file.
While using frameworks, like React, definitely has its benefits, muddling SoC can come at a cost when you start to scale your application and codebase.
This article looks at the importance of separation of concerns, how we can apply it to React apps, and proposes a pragmatic architecture to keep your logic and rendering separated. Adopting an approach like this will help your application scale smoothly as your codebase grows.
Motivations
This section goes into the theory and motivation for the proposed architecture. If you’re just interested in the solution & architecture, jump to the One Hook Per Screen Pattern section of the article.
Separation of Concerns
The web has traditionally been built with a very clear separation of concerns. HTML was used for content and structure, CSS for presentation and styling, and JavaScript for interactivity and business logic. This was done for many reasons, but among them was the simplification of each part of the app. If you encapsulate parts of your application with well defined boundaries, it should make it easier to understand and maintain.
In theory, with perfectly separated code, changes to one layer should not have an impact on the other layers. For example, if a developer wants to make changes to the presentation of the page, they do not need to worry about how it will affect the content or the business logic.
In today’s age of frameworks, we’ve strayed as far as possible from this. Let’s look at a typical React Native file for a simple screen:
This is a simple example with a screen that has an interactive button. As you press the button, a counter is incremented that updates the button text. This single JSX file has all three layers inside of it. The structural and presentation layers are intertwined together inside the return
and the interactivity/logic is being handled by the hooks at the top of the file (useState
and useCallback
).
That’s separation of concerns out the window… Now arguably frameworks, like React, let us develop interactive UIs quicker than ever before with massive speed boosts. That comes as a double edged sword - if you’re constantly building at a high speed and not taking a step back to analyse the foundations, your app will start to feel like it’s built on a house of cards.
Separating Business Logic from Rendering
A good starting point for understanding separation of concerns in traditional UI frameworks is the Model-View-Controller (MVC) pattern:
Source: FreeCodeCamp
- The Model handles the actual data and business logic of the app.
- The View is a representation of the data, which is derived from the model.
- The Controller is the interlay between the model and the view, handling user interactions and setting/getting data from the model.
This pattern is common in older frameworks, like Django and Ruby on Rails, and while it can become a bit boilerplate-y, it’s a very logical split of an app’s architecture and helps your codebase to scale well.
Let’s try and apply this to React now. In a common React file, you’ll have the following components:
- Business Logic (Model): things like getting data from databases, transforming data, configs
- Rendering Logic (View): the actual JSX inside the component along with any styling.
- UI State and Data Handling (Controller): the screen/component level logic and state.
Let’s go back to the original example we had above with the orange button. We’ve added some data fetching from a REST API to get an article title, and then increment the button text.
If we try to separate out the code for this screen, we find that it can pretty easily be split along the MVC-like split:
- At the top, the
fetchArticle
method is getting the model from the external API and handling the business logic. (Model) - In the middle of the file we have a bunch of hooks handling the local state of the button and the text that’s going to be displayed in a screen. (Controller)
- Lastly, we have the rendering logic, which includes the actual components being rendered, along with any styling. (View)
Since there’s a clear separability, each of these responsibilities can be taken care of in isolation.
One Hook Per Screen Pattern
As you can see in the example above, even simple screens/components can become very long and filled with a bunch of functions and hooks that have nothing to do with rendering. Initially, we attempted to combat this by introducing a linting rule to limit the number of lines per file. Over time, we found that this rule forced us to abstract away the business logic into separate files to adhere to this rule.
Ultimately, we landed on a simpler approach:
Each component/screen should only be calling a single hook. This hook would contain all of the UI state and data fetching, and would be making calls to retrieve business domain data.
Example
Let’s start abstracting away each part of the Screen file with this approach.
Business/Domain Logic
Firstly, we want to abstract away the domain/business logic - “articles” are entities here, so these aren’t specific to screens/components. We can abstract these away to a folder called domain
or modules
that contains all abstracted business logic.
Each subfolder within domain should be a single subdomain/class/entity in your business logic. These should roughly map to the split present on your application’s backend.
We’ll define a useArticles
hook that handles any logic related to fetching, updating, or transforming articles on the frontend.
Note: in this case, we don’t need it to be a hook, but in many cases, where you need to use application state (for example, accessing user details from an AuthProvider
), you will need a hook. We use hooks for all of our business logic to keep things consistent.
UI State & Data Handling
Inside the screens
/pages
folder, each screen should have a subfolder that contains a <Screen Name>.hook.ts
file which contains a use<Screen Name>
hook inside of it. This hook will act as a controller between the business domain and what’s being rendered in the UI.
In the case of our ArticleTitleScreen
example, we import the fetchArticle
domain defined in the useArticles
hook and use other hooks like useState
, useCallback
, and useEffect
to handle the screen’s state.
The hook will return everything that needs to be accessed by the rendering logic in the UI.
Rendering Logic
After abstracting away the hooks into separate files, our actual screen/component file should just contain rendering logic for the UI elements:
The final folder structure should look something like this:
Wrap Up
Separation of Concerns is important for creating scalable apps. This is often neglected in React/React Native.
We looked at a pragmatic architecture to keep business logic and rendering separate. The ‘One Hook Per Screen’ pattern abstracts business logic away to a separate folder and has each component/screen call a single hook containing UI state and data fetching. This will help the application scale smoothly as the codebase grows.
Feel free to reach out
Feel free to reach out to me on Twitter @mo__javad. 🙂