Skip to Content
TheorySOLID in React

SOLID in React: Clean Architecture

Clean architecture isn’t just for backend systems — it applies to frontend and React as well. By following the SOLID principles, you’ll write components that are easier to test, extend, and maintain — all while avoiding common pitfalls in growing codebases.

  • Single responsibility principle - Class has one job to do. Each change in requirements can be done by changing just one class
  • Open/closed principle - Class is happy (open) to be used by others. Class is not happy (closed) to be changed by others
  • Liskov substitution principle - Class can be replaced by any of its children. Children classes inherit parent’s behaviours
  • Interface segregation principle - When classes promise each other something, they should separate these promises (interfaces) into many small promises, so it’s easier to understand
  • Dependency inversion principle - When classes talk to each other in a very specific way, they both depend on each other to never change. Instead, classes should use promises (interfaces, parents), so classes can change as long as they keep the promise

Single responsibility principle

Don’t create too big components that have too much jobs to do. Break into smaller ones, name = description. Every component has one responsibility, first render card, second contains button that calls the dialog component

S
import { useState } from 'react' // bad const UserCard = ({ user }: { user: User }) => { const [open, setOpen] = useState(false) return ( <> <Dialog fullWidth maxWidth="md" open={open} onClose={() => setOpen(false)} > <EditUserDialog id={user.id} handleEditClose={() => setOpen(false)} /> </Dialog> <Button onClick={() => setOpen(true)}>edit user</Button> <Box>...here goes user card</Box> </> ) } // good const UserCard = ({ user }: { user: User }) => { return ( <> <EditUser id={user.id} /> <Box>...here goes user card</Box> </> ) } const EditUser = ({ id }: { id: number }) => { const [open, setOpen] = useState(false) return ( <> <Dialog fullWidth maxWidth="md" open={open} onClose={() => setOpen(false)} > <EditUserDialog id={id} handleEditClose={() => setOpen(false)} /> </Dialog> <Button>edit user</Button> </> ) }

Open/closed principle

Problem with first components is that we can’t pass another color without changing FancyIconButton code. In the second case, this problem is fixed, now we don’t need to change FancyIconButton2. So, it’s open to be used by others, closed to modification

O
// bad const FancyIconButton = ({ red, green, }: { red?: boolean green?: boolean }) => { const getBackgroundColor = () => { switch (true) { case red: return 'red' case green: return 'green' default: return 'black' } } return ( <> <IconButton color="secondary" sx={{ backgroundColor: getBackgroundColor() }} > <ArrowBackTwoToneIcon /> </IconButton> </> ) } // good const FancyIconButton2 = ({ backgroundColor = 'black', }: { backgroundColor: string }) => { return ( <> <IconButton color="secondary" sx={{ backgroundColor }}> <ArrowBackTwoToneIcon /> </IconButton> </> ) }

Liskov substitution principle

FancyIconButton can be replaced by IconButton. Children component inherits parent’s behaviours. This way we can have many buttons with different styles, but they all can be used same way as original IconButton

L
import { IconButton, IconButtonProps } from '@mui/material' const fancyStyles = {} // bad const FancyIconButton = () => { return ( <> <IconButton sx={{ ...fancyStyles }}> <ArrowBackTwoToneIcon /> </IconButton> </> ) } // good const FancyIconButton2 = ({ ...props }: IconButtonProps) => { return ( <> <IconButton {...props} sx={{ ...fancyStyles }}> <ArrowBackTwoToneIcon /> </IconButton> </> ) } const RandomComponent = () => { return ( <> {/*error no such props*/} <FancyIconButton size={'small'} /> {/*here ok*/} <FancyIconButton2 size={'small'} /> <IconButton onClick={() => null} /> </> ) }

Interface segregation principle

When classes promise each other something, they should separate these promises (interfaces) into many small promises, so it’s easier to understand

I
import { FC } from "react"; interface ButtonProps { onClick: () => void; onMouseOver: () => void; } const SubmitButton: FC<ButtonProps> = ({ onClick, onMouseOver }) => { return ( <button onClick={onClick} onMouseOver={onMouseOver}> Submit </button> ); }; // This forces `SubmitButton` to support all event handlers, even if not all are needed. // Good interface Clickable { onClick: () => void; } interface Hoverable { onMouseOver: () => void; } const ClickableButton: FC<Clickable> = ({ onClick }) => { return <button onClick={onClick}>Submit</button>; }; const HoverableDiv: FC<Hoverable> = ({ onMouseOver }) => { return <div onMouseOver={onMouseOver}>Hover over me!</div>; }; const SubmitButton2: FC<Clickable & Hoverable> = ({ onClick, onMouseOver }) => { return ( <button onClick={onClick} onMouseOver={onMouseOver}> Submit </button> ); }; const App = () => { return ( <div> <h1>Interface segregation in React</h1> {/* both are same */} <SubmitButton onClick={()=>null} onMouseOver={()=>null} /> <SubmitButton2 onClick={()=>null} onMouseOver={()=>null} /> {/* separated */} <ClickableButton onClick={()=>null} /> <HoverableDiv onMouseOver={()=>null} /> </div> ); }; export default App;

Dependency inversion principle

When classes talk to each other in a very specific way, they both depend on each other to never change. Instead, classes should use promises (interfaces, parents), so classes can change as long as they keep the promise

D
import { FC } from "react"; // Abstraction interface AuthProvider { login: (username: string, password: string) => Promise<boolean>; logout: () => void; } // High-level module (React component) const AuthButton: FC<{ authProvider: AuthProvider }> = ({ authProvider }) => { const handleLogin = async () => { const success = await authProvider.login("user", "password"); if (success) { console.log("Logged in successfully"); } else { console.error("Login failed"); } }; const handleLogout = () => { authProvider.logout(); console.log("Logged out"); }; return ( <div> <button onClick={handleLogin}>Login</button> <button onClick={handleLogout}>Logout</button> </div> ); }; // Implementation of the abstraction class FirebaseAuthProvider implements AuthProvider { async login(username: string, password: string): Promise<boolean> { // Simulate Firebase API call console.log(`Logging in with Firebase: ${username}`); return username === "user" && password === "password"; // Simulate success for demo } logout(): void { console.log("Logging out with Firebase"); } } // Another implementation class LocalAuthProvider implements AuthProvider { async login(username: string, password: string): Promise<boolean> { console.log(`Logging in locally: ${username}`); return username === "admin" && password === "1234"; // Simulate success for demo } logout(): void { console.log("Logging out locally"); } } // Using the component const App = () => { // Use any implementation of the abstraction const authProvider = new FirebaseAuthProvider(); const localProvider = new LocalAuthProvider(); return ( <div> <h1>Dependency Inversion in React</h1> <AuthButton authProvider={authProvider} /> <AuthButton authProvider={localProvider} /> </div> ); }; export default App;
Last updated on