Write SOLID React Hooks
SOLID is one of the more commonly used design patterns. It's commonly used in many languages and frameworks, and there are some articles out there how to use it in React as well.
Each React article about SOLID presents the model in slightly different ways, some applies it on components, other on TypeScript, but very few of them are applying the principles to hooks.
Since hooks are a part of React's foundation, we will here look at how the SOLID principles applies to those.
Single Responsibility Principle (SRP)
The first letter in Solid, the S, is the easiest one to understand. In essence it means, let one hook/component do one thing.
// Single Responsibility Principle
A module should be responsible to one, and only one, actor
For example, look at the useUser hook below, it fetches a user and todo tasks, and merges the tasks into the user object.
import { useState } from 'react'
import { getUser, getTodoTasks } from 'somewhere'
const useUser = () => {
const [user, setUser] = useState()
const [todoTasks, setTodoTasks] = useState()
useEffect(() => {
const userInfo = getUser()
setUser(userInfo)
}, [])
useEffect(() => {
const tasks = getTodoTasks()
setTodoTasks(tasks)
}, [])
return { ...user, todoTasks }
}
That hook isn't solid, it doesn't adhere to the single responsibility principle. This is because it both has the responsibility to get user data and todo tasks, that's two things.
Instead, the above code should be split in two different hooks, one to get data about the user, and another one to get the tasks.
import { useState } from 'react'
import { getUser, getTodoTasks } from 'somewhere'
// useUser hook is no longer responsible for the todo tasks.
const useUser = () => {
const [user, setUser] = useState()
useEffect(() => {
const userInfo = getUser()
setUser(userInfo)
}, [])
return { user }
}
// Todo tasks do now have their own hook.
// The hook should actually be in its own file as well. Only one hook per file!
const useTodoTasks = () => {
const [todoTasks, setTodoTasks] = useState()
useEffect(() => {
const tasks = getTodoTasks()
setTodoTasks(tasks)
}, [])
return { todoTasks }
}
This principle applies to all hooks and components, they all should only do one thing each. Things to ask yourself are:
- Is this a component which should show a UI (presentational) or handle data (logical)?
- What single type of data should this hook handle?
- What layer does this hook/component belong to? Is it handling data storage or is it maybe a part of a UI?
If you find yourself building a hook which doesn't have a single answer to each and every of the above questions, then you're breaking the single responsibility principle.
An interesting thing to note here, is question number one. That one actually means, that a component rendering a UI, should not also handle data. This means, to really follow this principle strictly, each React component displaying data should have a hook to handle its logic and data. In other words, data should not be fetched in the same component which displays it.
Why Use SRP in React?
This single responsibility principle actually goes very well with React. React follows a component based architecture, meaning that it consists of small components composed together so they all together can build up and form an application. The smaller the components are, the more likely they are to be reusable. This applies to both components and hooks.
For that reason, React is more or less founded on the single responsibility principle. If you don't follow it, you will find yourself always writing new hooks and component and rarely re-use any of them.
Disobeying the single responsibility principle will make your code exhaustive to test. You will often find your test files to have several hundred, maybe up towards 1000, lines of codes, if you don't follow this principle.
Open/Closed Principle (OCP)
Let's continue with the Open/Closed principle, after all, it's the next letter in SOLID. OCP is as well as SRP one of the easier principle to understand, at least its definition.
// Open/Closed Principle
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification
In words for dummies who recently have started with React, the sentence can be translated to:
Write hooks/component which you never will have a reason to touch again, only re-use them in other hooks/components
Think back at what was said for the single responsibility principle earlier in this article; in React, your are expected to write small components and compose them together. Let's look at why that is helpful.
import { useState } from 'react'
import { getUser, updateUser } from 'somewhere'
const useUser = ({ userType }) => {
const [user, setUser] = useState()
useEffect(() => {
const userInfo = getUser()
setUser(userInfo)
}, [])
const updateEmail = (newEmail) => {
if (user && userType === 'admin') {
updateUser({ ...user, email: newEmail })
} else {
console.error('Cannot update email')
}
}
return { user, updateEmail }
}
The hook above fetches a user and returns it. If the type of the user is an admin, the user is allowed to update its email. A regular user is not allowed to do update its email.
The above code would definitely not get you fired. It would annoy the backend guy in your team though, the dude who reads design pattern books as bedtime stories for his toddlers. Let's call him Pete.
What would Pete complain about? He would ask you to rewrite the component as shown below. To lift out the admin functionalities to it's own useAdmin hook, and leave the useUser hook with no other features than those that should be available for regular users.
import { useState } from 'react'
import { getUser, updateUser } from 'somewhere'
// useUser does now only return the user,
// without any function to update its email.
const useUser = () => {
const [user, setUser] = useState()
useEffect(() => {
const userInfo = getUser()
setUser(userInfo)
}, [])
return { user }
}
// A new hook, useAdmin, extends useUser hook,
// with the additional feature to update its email.
const useAdmin = () => {
const { user } = useUser()
const updateEmail = (newEmail) => {
if (user) {
updateUser({ ...user, email: newEmail })
} else {
console.error('Cannot update email')
}
}
return { user, updateEmail }
}
Why did Pete ask for this update? Because that disrespectful picky prick Pete would rather want you to spend time rewriting that hook now, and come back with a new code review tomorrow, instead of potentially having to update the code with a tiny new if statement in the future, if there ever would be another type of user.
Well, that's the negative way to put it... The optimistic way, is that with this new useAdmin hook, you don't have to change anything in the useUser hook when you intend to implement features that affects admin users only, or when you add new types of users.
When new user types are added, or when useAdmin hook is updated, there's no need to mess with the useUser hook or update any of its tests. Meaning, you don't have to accidentally ship a bug to regular users when you add a new user type, e.g. a fake user. Instead, you just add a new userFakeUser hook and your boss won't call you in at 9 pm on a Friday because customers experience problems with fake data being shown for their bank account on a salary weekend.
Pete's son knows to be careful about spaghetti code developers
Why Use OCP in React?
It's arguable how many hooks and components a React project should have. Each one of them comes with a cost of renderings. React isn't a Java where 22 design patterns leads to 422 classes for a simple TODO list implementation. That's the beauty of the Wild West Web (www).
However, open/closed principle is clearly a handful pattern to use in React as well. The example with the hooks above was minimal, the hooks didn't do very much. With more substantive hooks and larger projects this principle becomes highly important.
It might cost you some extra hooks, and take slightly longer to implement, but your hooks will become more extendable, meaning that you can re-use them more often. You will have to rewrite the tests less often, making the hooks more solid. And most important, you won't create bugs in old code if you never touch it.
God knows not to touch things which aren't broken
Liskov Substitution Principle (LSP)
Aaah, the name... Who the hedge is Liskov? And who will substitute her? And the definition, doesn't it even make sense?
If S subtypes T, what holds for T holds for S
This principle is clearly about inheritance, which isn't naturally practiced as much in React or JavaScript as in most of the backend languages. JavaScript didn't even have classes until ES6, that was introduced around 2015/2016 as syntactical sugar to prototype based inheritance.
With that in mind, the use cases of this principle really depends on what your code looks like. A principle similar to Liskov's that would make sense in React, could be:
If a hook/component accepts some props, all hooks and components which extends that hook/component must accept all the props the hook/component it extends accepts. The same goes for return values.
To illustrate an example of this, we can look at two storage hooks, useLocalStorage and useLocalAndRemoteStorage.
import { useState } from 'react'
import {
getFromLocalStorage, saveToLocalStorage, getFromRemoteStorage
} from 'somewhere'
// useLocalStorage gets data from local storage.
// When new data is stored, it calls saveToStorage callback.
const useLocalStorage = ({ onDataSaved }) => {
const [data, setData] = useState()
useEffect(() => {
const storageData = getFromLocalStorage()
setData(storageData)
}, [])
const saveToStorage = (newData) => {
saveToLocalStorage(newData)
onDataSaved(newData)
}
return { data, saveToStorage }
}
// useLocalAndRemoteStorage gets data from local and remote storage.
// I doesn't have callback to trigger when data is stored.
const useLocalAndRemoteStorage = () => {
const [localData, setLocalData] = useState()
const [remoteData, setRemoteData] = useState()
useEffect(() => {
const storageData = getFromLocalStorage()
setLocalData(storageData)
}, [])
useEffect(() => {
const storageData = getFromRemoteStorage()
setRemoteData(storageData)
}, [])
const saveToStorage = (newData) => {
saveToLocalStorage(newData)
}
return { localData, remoteData, saveToStorage }
}
With the hooks above, useLocalAndRemoteStorage can be seen as a subtype of useLocalStorage, since it does the same thing as useLocalStorage (saves to local storage), but also has extended the capability of the useLocalStorage by saving data to an additional place.
The two hooks have some shared props and return values, but useLocalAndRemoteStorage is missing the onDataSaved callback prop which useLocalStorage accepts. The name of the return properties are also named differently, local data is named as data in useLocalStorage but named as localData in useLocalAndRemoteStorage.
If you would ask Liskov, this would have broken her principle. She would be quite furious actually when she would try to update her web application to also persist data server side, just to realize that she cannot simply replace useLocalStorage with useLocalAndRemoteStorage hook, just because some lazy fingered developer never implemented the onDataSaved callback for the useLocalAndRemoteStorage hook.
Liskov would bitterly update the hook to support that. Meanwhile, she would also update the name of the local data in the useLocalStorage hook to match the name of the local data in useLocalAndRemoteStorage.
import { useState } from 'react'
import {
getFromLocalStorage, saveToLocalStorage, getFromRemoteStorage
} from 'somewhere'
// Liskov has renamed data state variable to localData
// to match the interface (variable name) of useLocalAndRemoteStorage.
const useLocalStorage = ({ onDataSaved }) => {
const [localData, setLocalData] = useState()
useEffect(() => {
const storageData = getFromLocalStorage()
setLocalData(storageData)
}, [])
const saveToStorage = (newData) => {
saveToLocalStorage(newData)
onDataSaved(newData)
}
// This hook does now return "localData" instead of "data".
return { localData, saveToStorage }
}
// Liskov also added onDataSaved callback to this hook,
// to match the props interface of useLocalStorage.
const useLocalAndRemoteStorage = ({ onDataSaved }) => {
const [localData, setLocalData] = useState()
const [remoteData, setRemoteData] = useState()
useEffect(() => {
const storageData = getFromLocalStorage()
setLocalData(storageData)
}, [])
useEffect(() => {
const storageData = getFromRemoteStorage()
setRemoteData(storageData)
}, [])
const saveToStorage = (newData) => {
saveToLocalStorage(newData)
onDataSaved(newData)
}
return { localData, remoteData, saveToStorage }
}
By having common interfaces (ingoing props, outgoing return values) to hooks, they can become very easy to exchange. And if we should follow the Liskov substitution principle, hooks and components which inherits another hook/component should be possible to substitute with the hook or component it inherits.
Liskov gets disappointed when developers don't follow her principles
Why Use LSP in React?
Even though inheritance isn't very prominent in React, it's definitely used behind the scenes. Web applications can often have several similar looking components. Texts, titles, links, icon links and so on are all similar types of components and can benefit of being inherited.
An IconLink component may or may not be wrapping a Link component. Either way, they would benefit from being implemented with the same interface (using the same props). In that way, it's trivial to replace a Link component with an IconLink component anywhere in the application at any time, without having to edit any additional code.
The same goes for hooks. A web application fetches data from servers. They might use local storage as well or a state management system. Those can preferably share props to make them interchangeable.
An application might fetch users, tasks, products or any other data from backend servers. Functions like that might as well share interfaces, making it easier to re-use code and tests.
Interface Segregation Principle (ISP)
Another bit more clear principle, is the Interface Segregation Principle. The definition is quite short.
No code should be forced to depend on methods it does not use
As its name tells, it has to do with interfaces, basically meaning that functions and classes should only implement interfaces it explicitly use. That is easiest achieved by keeping interfaces neat and letting classes pick a few of them to implement instead of being forced to implement one big interface with several methods it doesn't care about.
For instance, a class representing a person who owns a website should be implementing two interfaces, one interface called Person describing the details about the person, and another interface for the Website with metadata about the Website it owns.
interface Person {
firstname: string
familyName: string
age: number
}
interface Website {
domain: string
type: string
}
If one instead, would create a single interface Website, including both information about the owner and the website, that would disobey the interface segregation principle.
interface Website {
ownerFirstname: string
ownerFamilyName: number
domain: string
type: string
}
You may wonder, what is the problem with the interface above? The problem with it is that it makes the interface less usable. Think about it, what would you do if the company wasn't a human, instead a company. A company doesn't really have a family name. Would you then modify the interface to make it usable for both a human and a company? Or would you create a new interface CompanyOwnedWebsite?
You would then end up with an interface with many optional attributes, or respectively, two interfaces called PersonWebsite and CompanyWebsite. Neither of these solutions are optimal.
// Alternative 1
// This interface has the problem that it includes
// optional attributes, even though the attributes
// are mandatory for some consumers of the interface.
interface Website {
companyName?: string
ownerFirstname?: string
ownerFamilyName?: number
domain: string
type: string
}
// Alternative 2
// This is the original Website interface renamed for a person.
// Which means, we had to update old code and tests and
// potentially introduce some bugs.
interface PersonWebsite {
ownerFirstname: string
ownerFamilyName: number
domain: string
type: string
}
// This is a new interface to work for a company.
interface CompanyOwnedWebsite {
companyName: string
domain: string
type: string
}
The solution which would follow the ISP, would look like this.
interface Person {
firstname: string
familyName: string
age: number
}
interface Company {
companyName: string
}
interface Website {
domain: string
type: string
}
With the proper interfaces above, a class representing a company website could implement the interfaces Company and Website, but would not need to consider the firstname and familyName properties from the Person interface.
Is ISP Used in React?
So, this principle obviously applies to interfaces, meaning that it should only be relevant if you are writing React code using TypeScript, shouldn't it?
Of course not! Not typing interfaces doesn't mean they aren't there. There are there all over the place, it's just that you don't type them explicitly.
In React, each component and hook has two main interfaces, it's input and its output.
// The input interface to a hook is its props.
const useMyHook = ({ prop1, prop2 }) => {
// ...
// The output interface of a hook is its return values.
return { value1, value2, callback1 }
}
With TypeScript, you normally type the input interface, but the output interface is often skipped, since it is optional.
// Input interface.
interface MyHookProps {
prop1: string
prop2: number
}
// Output interface.
interface MyHookOutput {
value1: string
value2: number
callback1: () => void
}
const useMyHook = ({ prop1, prop2 }: MyHookProps): MyHookOutput => {
// ...
return { value1, value2, callback1 }
}
If the hook wouldn't use prop2 for anything, then it should not be a part of its props. For a single prop, it would be easy to remove it from the props list and interface. But what if prop2 would be of an object type, for instance the improper Website interface example from the previous chapter?
interface Website {
companyName?: string
ownerFirstname?: string
ownerFamilyName?: number
domain: string
type: string
}
interface MyHookProps {
prop1: string
website: Website
}
const useMyCompanyWebsite = ({ prop1, website }: MyHookProps) => {
// This hook uses domain, type and companyName,
// but not ownerFirstname or ownerFamilyName.
return { value1, value2, callback1 }
}
Now we have a useMyCompanyWebsite hook, which has a website prop. If parts of the Website interface is used in the hook, we cannot simple remove the whole website prop. We have to keep the website prop, and thereby also keep the interface props for ownerFirstname and ownerFamiliyName. Which also means, that this hook intended for a company could be used by a human owned website owner, even though this hook likely wouldn't work appropriately for that usage.
Why Use ISP in React?
We have now seen what ISP means, and how it applies to React, even without the usage of TypeScript. Just by looking at the trivial examples above, we have seen some of the problems with not following the ISP as well.
In more complex projects, readability is of the greatest matter. One of the purpose of the interface segregation principle is to avoid cluttering, the existence of unnecessary code which only are there to disrupt readability. And not to forget about, testability. Should you care about the test coverage of props you are not actually using?
Implementing large interfaces also forces you to make props optional. Leading to more if statements to check presences and potential misusages of functions because it appears on the interface that the function would handle such properties.
Dependency Inversion Principle (DIP)
The last principle, the DIP, includes some terms which are quite misunderstood out there. The confusions are much about what the difference is between dependency inversion, dependency injection and inversion of control. So let's just declare those first.
Dependency Inversion
Dependency Inversion Principle (DIP) says that high-level modules should not import anything from low-level modules, both should depend on abstractions. What this means, is that any high level module, which naturally could be dependent on implementation details of modules it uses, shouldn't have that dependency.
The high and low-level modules, should be written in a way so they both can be used without knowing any details about the other module's internal implementation. Each module should be replaceable with an alternative implementation of it as long as the interface to it stays the same.
Inversion of Control
Inversion of Control (IoC) is a principle used to address the dependency inversion problem. It states that dependencies of a module should be provided by an external entity or framework. That way, the module itself only has to use the dependency, it never has to create the dependency or manage it in any way.
Dependency Injection
Dependency injection (DI) is one common way to implement IoC. It provides dependencies to modules by injecting them through constructors or setter methods. In that way, the module can use a dependency without being responsible of creating it, which would live up to the IoC principle. Worth to mention, is that dependency injection isn't the only way to achieve inversion of control.
Is DIP Used in React?
With the terms clarified, and knowing that the DIP principle is about dependency inversion, we can look at how that definition looks again.
High-level modules should not import anything from low-level modules. Both should depend on abstractions
How does that apply to React? React isn't a library which normally is associated with dependency injection, so how can we then solve the problem of dependency inversion?
The most common solution to this problem spells hooks. Hooks cannot be counted as dependency injection, because they are hardcoded into components and it's not possible to replace a hook with another without changing the implementation of the component. The same hook will be there, using the same instance of the hook until a developer updates the code.
But remember, dependency injection is not the only way to achieve dependency inversion. Hooks, could be seen as an external dependency to a React component, with an interface (its props) which abstracts away the code within the hook. In that way, a hook kind of implements the principle of dependency inversion, since the component depends on an abstract interface without needing to know any details about the hook.
Another more intuitive implementations of DIP in React which actually uses dependency injection are the usage of HOCs and contexts. Look at the withAuth HOC below.
const withAuth = (Component) => {
return (props) => {
const { user } = useContext(AuthContext)
if (!user) {
return <LoginComponent>
}
return <Component {...props} user={user} />
}
}
const Profile = () => { // Profile component... }
// Use the withAuth HOC to inject user to Profile component.
const ProfileWithAuth = withAuth(Profile)
The withAuth HOC shown above provides a user to the Profile component using dependency injection. The interesting thing about this example is that it not only shows one usage of dependency injection, it actually contains two dependency injections.
The injection of the user to the Profile component isn't the only injection in this example. The withAuth hook does in fact also get the user by dependency injection, through the useContext hook. Somewhere in the code, someone has declared a provider which injects the user into the context. That user instance can even be changed in runtime by updating the user in the context.
Why Use DIP in React?
Even though dependency injection isn't a pattern commonly associated with React, it is actually there with HOCs and contexts. And hooks, which has taken a lot of market share from both HOCs and contexts, does also confirm well with the dependency inversion principle.
DIP is therefore already built into the React library itself and should of course be utilized. It's both easy to use and provides advantages such as loose coupling between modules, hook and component reusability and testability. It also makes it easier to implement other design patterns such as the Single Responsibility Principle.
What I would discourage from, is trying to implement smart solutions and overusing the pattern when there really is much simpler solutions available. I have seen suggestions on the web and in books to use React contexts for the sole purpose of implementing dependency injection. Something like below.
const User = () => {
const { role } = useContext(RoleContext)
return <div>{`User has role ${role}`}</div>
}
const AdminUser = ({ children }) => {
return (
<RoleContext.Provider value={{ role: 'admin' }}>
{children}
</RoleContext.Provider>
)
}
const NormalUser = ({ children }) => {
return (
<RoleContext.Provider value={{ role: 'normal' }}>
{children}
</RoleContext.Provider>
)
}
Although the above example does inject a role into the User component, it's purely overkill to use a context for it. React contexts should be used when appropriate, when the context itself serves a purpose. In this very case, a simple prop would have been a better solution.
const User = ({ role }) => {
return <div>{`User has role ${role}`}</div>
}
const AdminUser = () => <User role='admin' />
const NormalUser = () => <User role='normal' />