"React.js - A JavaScript library for building user interfaces." From the title on React's homepage, we can clearly understand: React does not have a built-in architecture since it's not a framework, just a thin library.
Before discussing the problems with the missing architecture in React, I want to reference the concept of "Domain-Driven Design."
"Domain-Driven Design is an approach to software development that centers the development on programming a domain model that has a rich understanding of the processes and rules of a domain. The name comes from a 2003 book by Eric Evans that describes the approach through a catalog of patterns."
— Martin Fowler
In my practice, I refer to a domain when I talk about the area of expertise of a piece of code. It can be the business domain, like the banking domain in a fintech application. It can also be the technical domain, like HTTP logic handling, error handling, and configuration for REST communication.
This abstraction makes it possible to avoid all the architectural patterns known in DDD and simply use the idea of code responsibility to a certain type of logic (the domain). With that in place, we can start a discussion about React.js architecture.
Signs of a Missing React Architecture
- ✗ No separation between pure UI and domain-specific UI logic — you cannot extract your pure layout components for reuse across other projects in your company
- ✗ Following the smart and dumb components philosophy, the pure UI components are so dumb that they become difficult to use — countless functions are passed into the component without any default values, so you need to know everything about them just to use them
- ✗ New developers struggle to reuse logic because the entire codebase consists of single exported methods with abstract names — auto-completion is impossible without knowing the specific method names
- ✗ Architectural layers are mixed up — the highest view components directly handle HTTP response codes with no central place for unified error handling
- ✗ The concept of domain-specific logic does not exist — each feature randomly builds up its calculations directly in the "smart component," mixed with its own UI logic
A Look Back at Architectural Freedom
This is the result of architectural freedom. We had similar situations before opinionated frameworks came into play. I remember well when Ruby on Rails emerged around 2006. As a mainstream framework, it had a built-in architecture. All major startup products were written in Rails for almost a decade with great success. RoR achieved tremendous success because it provided direction, a complete out-of-the-box architecture, and a fully configured technical stack.
So why are we abandoning those lessons learned?
Solutions are on the way, though it's taking longer than it should. Angular, for example, does NOT have this issue — it has a built-in architecture. Frameworks like Next.js have also understood this problem and are building more opinions into their architecture. However, Next.js still lacks a domain-oriented approach — specifically, it's missing a separation into domain and technical services as part of the framework.
The Simplified Domain Architecture
Since no major domain orientation exists, you're better off addressing this topic and defining an architecture within your team that works for everyone. One architectural style that has worked well for me in many projects is what I call the "Simplified Domain Architecture."
UI Plain Domain
This layer holds only UI components that are not bound to the business. Imagine writing another application and being able to just reuse your UI components: text fields, buttons, forms, and panels. UI plain components should be targeted as a shareable library for your entire corporation.
A very elegant approach is provided by the nx.dev monorepo framework, where you can easily define shareable libraries. The plain UI domain should never reference any context API or domain services to remain fully shareable. Dependencies flow only into this layer, never out of it.
UI Business Domain
This layer contains your business-oriented features. The UI business domain references mainly the plain UI and the business service domain to implement features. It can also reference some tech services — for example, a routing service is UI-related but can be considered a tech service since it's not domain-specific.
Service Business Domain
These are your business-specific services. For a banking application, you would coordinate business flows and business logic here. The business domain uses tech services to execute its tasks and creates an abstraction over pure tech. A business service can decide on the right error handling before passing information to the UI. The UI has an easier life, plus you never repeat yourself since you consolidate logic for one business case in your business service.
Example: PhoneNumberService
import parsePhoneNumber from "libphonenumber-js/max";
const DEFAULT_COUNTRY = "DE";
const INVALID_NUMBER_MSG = "Your phone number is not valid.";
const readPhoneNumber = (value: string) => {
return parsePhoneNumber(value, {
defaultCountry: DEFAULT_COUNTRY,
extract: false,
});
};
const isValidNumber = (value: string, allowEmpty = false): boolean => {
if (allowEmpty && !value) {
return true;
}
const phoneNumber = readPhoneNumber(value);
return phoneNumber ? phoneNumber.isValid() : false;
};
const formatNumberInternationally = (value: string): string => {
const phoneNumber = readPhoneNumber(value);
return phoneNumber ? phoneNumber.formatInternational() : value;
};
const PhoneNumberService = {
INVALID_NUMBER_MSG,
isValidNumber,
formatNumberInternationally,
};
export default PhoneNumberService;
Key Benefits of This Pattern:
- ✓ Clear encapsulation of private methods
- ✓ A clear API to the outside
- ✓ Encapsulation of external libraries
- ✓ Reuse of internal private methods
- ✓ Domain-specific validation messages
- ✓ Any UI component using it gets logic from a single place
Service Tech Domain
Everything that is not business can be seen as your tech service — it's also your app infrastructure. Typical examples include:
- • HTTP: Wrap your HTTP client library here
- • Logging: Setting up log levels
- • Environment: Wrap your ENVs, calculate URLs for the backend
- • I18N: Translation logic
- • DateTime: Wrap external libraries like moment.js
- • Routing: Wrap standard routing into methods
Global State Management
If you have a mid-size React app with nothing special, you should be fine using just the Context API that comes out of the box with React. Important considerations:
- • You can use context from everywhere as long as you structure your service methods as hooks
- • If you don't need a service method to be a hook, make it a simple function — this makes usage more flexible and simpler
- • Ideally, keep your context only in the business UI domain and business domain, though this isn't always possible
- • I've completed entire projects with this architecture while completely avoiding global state — try to delay the introduction of global state as long as possible
Directed Dependency Flow
Dependencies flow mostly unidirectional, top-down, similar to backend architectures. Tech services don't know about the UI and business domain. The business domain picks what it needs from the system and references tech services from all levels, always in one direction.
Tech services can reference other tech services (for example, the HTTP service can reference the EnvService to figure out the URL it needs). However, tech services can never reference higher layers like business or UI. Specifically, it's forbidden to mix up objects from different layers in method signatures, such as passing HTTP domain-specific object types to other domains.
The Service Object Pattern
One thing that surprised me in JavaScript projects is the sloppy way of handling method visibility — everything is exported, any variable and any method. Additionally, these exports are mostly placed in single files! Not only do you pollute the code namespace, but you also end up with thousands of single files.
I understand the original motivation: tree shaking. This is fine if you import lodash and just want to use a single method from it. But why would you want to tree shake your own methods if your service has just 5 of them? Will a little tree shaking of your own methods decrease your bundle size enough to justify building your whole repo that way? I really don't think so — this is not where you should be optimizing your bundles.
Example: Service Object Pattern
export interface MyDomainType {
someAttrib: string;
}
const myPrivateMethod = () => {
console.log('Do something...')
}
const myMethod = (param?: MyDomainType): MyDomainType => {
myPrivateMethod()
console.log('called with', param);
return { ...param };
};
export const MyDomainService = {
myMethod,
};
export default MyDomainService;
Advantages of the Service Object Pattern:
- ✓ Clear private and public visibility with encapsulation
- ✓ Namespace and readability when calling your methods
- ✓ Easy auto-completion by writing out the service name
- ✓ Easier logic reuse and recognition of repetition
- ✓ Clear place for other team members to look for written logic
Advantages of This Architecture
Pragmatic
From my 20 years of experience, this is a perfect balance between a purist architecture and no architecture at all. You can have the cleanest architecture, but will you be able to maintain it in a realistic project? Will your junior team members clearly understand it? We need a balance of theory and practical application.
Easy to Introduce
Show the theory to a team and apply it if everyone agrees. No need to restructure the whole project — it can happen incrementally and only for new features, because it doesn't have a bunch of conventions or a fancy folder structure.
Backend Best Practices
It's very similar to a Spring backend architecture. Many backend developers will feel comfortable with this approach since this is what they mostly do.
Logic in a Single Place
Have you ever had the feeling that no central place exists for some type of logic? The domain approach forces developers to push logic types to the right place.
Domain Namespaces
The JS ecosystem is known for exporting many single functions to the global namespace. With this architecture, you can easily write out a service name like "UserService." and use your IDE's auto-completion. The rule is: "don't read, just auto-complete."
No Other Patterns
Forget any discussion about helpers, utils, or APIs. It's just "YourSomethingService." It gives your logic its area of task and can be found easily by others. The team agrees on one main pattern that performs well for most situations.
React Friendly
With hooks down to the tech services, you can always use the global Context API to consume or write to state if needed in your architecture.
Encapsulation of External Libraries
Any external library is placed into a corresponding service. Even global browser objects like localStorage, window, and events are placed into a service and abstracted away.
Test Friendly
By separating your app into layers and services, it becomes very test-friendly. In my tests, I call the same infrastructure services like EnvService or InitializationService that are used in the app.
Final Thoughts
This architecture is not purist — there are more purist approaches. But each architecture brings its costs with it. With this architecture, I tried to create a perfect balance between simplicity, domain-driven design ideas, and a more purist approach like CLEAN architecture.
I will discuss a more purist architecture (CLEAN) in my next blog post...