UI Design Patterns and Architecture
There are many different ways to architect a UI.
This section will cover some of the most common patterns for React and SPAs. Many of these patterns are also applicable to other frontend applications.
note: There are hybrids of these patterns, and there are many other variations of patterns that are not covered here. For example, it is common the Redux/Flux pattern is used in conjunction with any other React architecture.
At a high level there are several types of React Apps possible, including full stack:
1. App Rendering Architecture
Single Page Application (SPA) Architecture
- The original and classic style of making a React app, frontend only.
- The entire application is loaded once and the user interacts with it without needing to reload the page.
- The UI is typically composed of reusable React components that are dynamically rendered based on user actions.
- This can be served from a simple static file server, or even as a static site from a S3 bucket and CDN.
SPA use cases
- High Interactivity: Apps that require a high level of interactivity, such as social networking, e-commerce, and productivity apps.
- Real-time Updates: Real-time updates, such as chat applications or live sports updates. Use WebSockets or other similar technologies to push updates to the user in real-time.
- Rich Media: Display of rich media, such as videos, animations, and interactive graphics.
- Offline Availability: A client-side SPA can also be designed to work offline, caching data and resources on the client-side.
- Reduced Server Load: A client-side SPA can reduce server load by offloading some of the processing to the client-side.
Static Site Generated (SSG) Architecture
- The application is built as a static site.
- HTML markup and interactivity with Javascript is generated at build time.
- Examples include Docusaurus, Gatsby and Next.js.
- This makes it easy to deploy the application and is good if SEO is needed.
- It can be hosted on a static file server or CDN.
- It's very fast, scalable and best suited for data that does not change frequently.
- Most SSG frameworks do allow some capability to generate dynamic pages, but it's not the primary use case.
SSG use cases
- Docusaurus, Gatsby and Next.js are all SSG frameworks.
- Sites with a lot of static data, such as documentation sites, marketing sites, and landing pages.
- High traffic mostly/all static pages benefitting from SEO and reducing server load due to high demand.
- PWAs built using React SSG architecture can use Service Workers to cache static files, making them available offline.
- Landing pages, marketing topical information/image sites and mostly static asset sites.
Incremental Static Regeneration
- Frontend and server-side rendering on an SSG site.
- Next.js offers similar to SSG but refreshes it: Static site that allows for some pages to be dynamic and re-rendered on-demand on the server-sides.
- Pages can automatically regenerate on the server-side at specified intervals or when triggered by specific events.
- Updated incrementally on the server-side and then re-saved as static files for future requests, but are not always re-generated on every request.
- "Pre-render certain pages, and render the other pages on-demand" - Vercel
- "Incremental Static Generation allows us to only pre-render a subset of pages, for example pages that are likely to be requested by the user, and render the rest on-demand."
- Helps with SEO, server load and performance.
Server-side Rendering (SSR) Architecture
- Full stack React app, with a backend server that generates the HTML markup, props, dynamic javascript for the application.
- The server generates the HTML markup and sends it to the client, where it is then hydrated into a React application.
- Useful for pages that require up-to-date data on each request.
- All requests for a page results in the server generating the HTML for that page dynamically, based on the current state of the application and any external data sources.
- The page can contain fully dynamic content, and any changes to that content will be reflected in real-time on subsequent requests.
- SSR can improve performance, as it reduces the time to first render and improves SEO.
Use cases for SSR
- Server-side performance boost for dynamic content.
- E-commerce Sites, product pages, SEO.
- Social Networking sites that may combine interactivity with dynamic content.
- News and Media sites requiring SEO and have a lot of static content.
- Enterprise Applications benefiting from reduced server load and improved performance.
Streaming Server-Side Rendering
- "Generate HTML on every request, sending it down piece by piece as it becomes available." - Vercel
- "With regular Server-Side rendering, the user has to wait for the entire HTML to be generated on the server before it gets send down to the client.
- Before hydration could begin, the entire bundle had to be downloaded and executed."
- "However, with streaming server-side rendering, the components get streamed down as soon as they're ready."
- "This means that the user can start interacting with the page as soon as the first component is ready."
- Good for large datasets, heavy UIs, and real-time data.
- May require more advanced server requirements, and additional dev expertise.
- Backend Runtimes - Next.js allows 2 different runtimes which can be configured Node.js (Server/Serverless) and Edge.
React Server Components
- This is still considered experimental in React 18.
- It's a new way to render React components on the server, which is built into React 18.
- Rather than rendering the entire application on the server, React Server Components render only the components needed.
- The advantage is that components, rather than full pages can be rendered on the server, and then hydrated on the client.
Progressive Web App (PWA)/App Shell Architecture
- This combines web, mobile and native expected features.
- PWAs are web applications that can be installed on the user's mobile or desktop device and can be accessed offline.
- Performance is a key feature of PWAs, and they can be fast, responsive applications that work offline.
- The application is designed to be accessible on different devices, including mobile and desktop, and it can work offline or with a weak network connection. PWAs use a combination of technologies, including service workers, Web App Manifests, and responsive design.
- To implement the Google App Shell pattern, developers use service workers, caching strategies, and client-side rendering.
T3 Stack
- T3 stack with create-t3-app is a newer (2022) open-source fullstack React framework that combines Typescript, TailwindCSS and tRPC (API) with Next.Js/React, NextAuth and Prisma (DB ORM).
- T3 was originally popularized by Theo a well-regarded Javascript/React dev notable for daily videos on YouTube who was using it bootstrap his own startups.
- The create-t3-app library garnered 15k+ github stars in a short time. It was built and is now maintained by the T3 OSS community.
- It's a newer opinionated framework approach for React developers who want fullstack and the integrated convenience of an ORM, api endpoints with tRPC on Next.js, and simplified front-to-backend typesafety, which is more difficult in other REST/GraphQL approaches.
- It's often deployed to Vercel (but doesn't have to be) and a DBaaS using MySQL,Postgres, or other Prisma integrations.
- Downsides: Prisma not as performant for edge/lambda runtimes, tRPC is newer and lends itself to tighter coupling of front/backend and if non-JS support is needed may be inconsistent. Next.js is opinionated on some things like router and builder. Some debate whether Next.js is performant enough for edge runtimes and may have cold start issues with the edge services.
T3 Turbo Stack - Monorepo Web (Next.js) and Mobile (React Native)
- Expanding on T3 web stack, there is also an monorepo Next.js and Expo React Native mobile version: create-t3-turbo
- This uses Expo, React Native, Expo Router, NativeWind (TailwindCSS for React Native), and tRPC.
- create-t3-turbo Turborepo is used at the monorepo level to manage the shared code between the web Next.js and mobile Expo apps.
Monorepo Architecture
- This is more of a code organizational pattern, and combined with other patterns.
- In this architecture, the frontend and backend are combined into a single repository.
- This makes it easier to share code between the frontend and backend, and it also makes it easier to deploy the application.
- Various additional optimizations can be made, such as sharing components between the frontend and backend.
- Nix and Turborepo (acquired by Vercel) are examples of tools that can be used to manage a monorepo.
Micro Frontends Architecture
- Monorepo architecture, but the frontend is further divided into smaller, independent applications that work together.
- In this architecture, the frontend is composed of multiple small, independent applications, each with its own UI and business logic.
- These applications can be developed and deployed independently, and they can communicate with each other using APIs.
- NX is a tool fro microfrontends. In NX, developers typically follow the following steps:
- Identify the boundaries: Developers first need to identify the boundaries of each microfrontend and define the APIs for communicating between them.
- Create the microfrontends: Using the Nx CLI, developers can create new microfrontends with a specific set of tools and guidelines that ensure consistency and maintainability.
- Build the application: Once the microfrontends are created, developers can use the Nx CLI to build the entire application, which automatically resolves dependencies between the microfrontends.
- Test the application: Nx provides tools for testing each microfrontend separately, as well as tools for end-to-end testing of the entire application.
- Deploy the application: With Nx, developers can deploy each microfrontend independently, allowing them to update and scale the application without affecting the entire system.
Micro-Frontend Federation Architecture
- Variation of the micro-frontend architecture, where the frontend is composed of multiple small, independent applications, each with its own UI and business logic.
- Applications can be developed and deployed independently, and they can communicate with each other using APIs.
- Applications are not developed and deployed independently. Instead, they are developed and deployed as a single application, which is then split into multiple microfrontends.
- Webpack 5 Module Federation is a tool that can be used to implement this architecture.
Redux/Flux Architecture
- Application data flows in a unidirectional manner, from the view to the actions to the store and back to the view. This pattern is used to manage state in a predictable and maintainable way, and it is often used to manage state with React applications that have complex data flows.
- Redux and Redux Toolkit provide a popular way to implement this pattern.
- RTK Query can be used for API fetching, integrated with Redux
React Architecture Flowchart
2. App Design Patterns
Atomic Design Pattern
- This is a design pattern that is used to organize the UI components of an application. It is based on the concept of breaking down the UI into smaller, reusable components.
- The pattern is based on the idea that the UI can be broken down into smaller, reusable components. The components are organized into five categories: atoms, molecules, organisms, templates, and pages.
- Atom
- The smallest building block of an application. Atoms are typically HTML elements, such as a form label, input or button.
- Molecule:
- A group of atoms bonded together and are the smallest fundamental units of a compound. Examples: multiple HTML elements, such as a form, menu or card.
- Organism:
- A group of molecules joined together to form a relatively complex, distinct section of an interface. Examples: Header, footer or sidebar.
- Template:
- A page or screen that represents the highest level of a design system. Composed of organisms and molecules.
- Pages
- A specific instance of a template that shows what a UI looks like with content in place.
Rigid Atomic Design
- Same as Atomic design but simplifies by only using atoms, molecules and organisms, and pages.
MVVM
- Model-View-ViewModel
- The Model represents the data layer, which could be anything from a simple JavaScript object to a more complex API response.
- The View is the UI layer, which is made up of React components that are responsible for rendering the UI based on the data from the Model.
- The ViewModel acts as a mediator between the Model and the View, and is responsible for handling any business logic and data transformations necessary to display the data in the View.
- Key benefits: separation of concerns, maintainability, testability, and reusability.
Redux
- The application state is stored in a Redux store, which is created using the configureStore() function provided by Redux Toolkit.
- Actions are dispatched to the store using action creators, which are functions that create and return action objects.
- Reducers are responsible for updating the store's state in response to dispatched actions. In Redux Toolkit, reducers are created using the createSlice() function, which generates a slice of the state and a set of action creators to update that slice.
- Components can access the state and dispatch actions using hooks provided by the react-redux library. These hooks include useSelector(), which allows components to access specific parts of the state, and useDispatch(), which allows components to dispatch actions to the store.
- A set of utility functions and abstractions that simplify the development process and help reduce boilerplate code. For example, the createSlice() function generates reducers and action creators based on a template, eliminating the need for developers to manually define these functions.
3. Component Design Patterns
Module pattern
- Encapsulate functionality in a reusable module.
Container and Presentational Components
- Separating components into presentational and container components can help to keep components focused on rendering UI elements and handling data flow respectively.
// Presentational component
function Button(props) {
return (
<button onClick={props.onClick}>
{props.label}
</button>
);
}
// Container component
class ButtonContainer extends React.Component {
handleClick() {
console.log('Button clicked');
}
render() {
return (
<Button
onClick={this.handleClick}
label="Click me"
/>
);
}
}
Higher-Order Components (HOC)
- HOC is a pattern that allows developers to reuse component logic by wrapping components in higher-order functions.
// HOC
function withLogger(WrappedComponent) {
return function(props) {
React.useEffect(() => {
console.log(`Component ${WrappedComponent.name} mounted`);
}, []);
return <WrappedComponent {...props} />;
}
}
// Component
function MyComponent(props) {
return <div>Hello, world!</div>;
}
// Wrapped component with HOC
const MyComponentWithLogger = withLogger(MyComponent);
Render Props
- Render props is a pattern that allows components to share functionality by passing a function as a prop.
import * as React from "react";
interface ToggleProps {
render: (args: { on: boolean; toggle: () => void }) => React.ReactNode;
}
const Toggle: React.FC<ToggleProps> = ({ render }) => {
const [on, setOn] = React.useState(false);
const toggle = React.useCallback(() => {
setOn((prevOn) => !prevOn);
}, []);
return <>{render({ on, toggle })}</>;
};
function App() {
return (
<Toggle
render={({ on, toggle }) => (
<div>
{on ? "On" : "Off"}
<button onClick={toggle}>Toggle</button>
</div>
)}
/>
);
}
Controlled Components
- Controlled components are components that derive their state from props, and use callbacks to update the state in the parent component.
// Component
import React, { useState } from 'react';
type LoginData = {
username: string;
password: string;
};
type LoginFormProps = {
onSubmit: (data: LoginData) => void;
};
function LoginForm({ onSubmit }: LoginFormProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
onSubmit({ username, password });
}
return (
<form onSubmit={handleSubmit}>
<input type="text" value={username} onChange={e => setUsername(e.target.value)} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
<button type="submit">Log in</button>
</form>
);
}
function App() {
function handleLogin(data: LoginData) {
console.log(data);
}
return <LoginForm onSubmit={handleLogin} />;
}
Compound Components
- Compound components are components that work together to achieve a specific functionality, but can also be used independently.
// Component
function Tabs(props) {
const [activeIndex, setActiveIndex] = React.useState(0);
function handleTabClick(index) {
setActiveIndex(index);
}
return (
<div>
{React.Children.map(props.children, (child, index) => {
return React.cloneElement(child, {
isActive: index === activeIndex,
onClick: () => handleTabClick(index)
});
})}
</div>
);
}
function Tab(props) {
return (
<div onClick={props.onClick}>
{props.label} {props.isActive && '(Active)'}
</div>
);
}
// Usage
function App() {
return (
<Tabs>
<Tab label="Tab 1">Content for Tab 1</Tab>
<Tab label="Tab 2">Content for Tab 2</Tab>
<Tab label="Tab 3">Content for Tab 3</Tab>
</Tabs>
);
}
Context/Reducer
- The Context API allows components to share data without passing it through intermediate components.
// Context
const CounterContext = React.createContext();
// Reducer
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
// Component
function Counter(props) {
const [state, dispatch] = React.useReducer(counterReducer, { count: 0 });
return (
<CounterContext.Provider value={{ state, dispatch }}>
<div>
Count: {state.count}
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
</div>
</CounterContext.Provider>
);
}
// Usage
function CounterDisplay() {
const { state } = React.useContext(CounterContext);
return (
<div>
Count: {state.count}
</div>
);
}
function CounterButtons() {
const { dispatch } = React.useContext(CounterContext);
return (
<div>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
</div>
);
}
function App() {
return (
<Counter>
<CounterDisplay />
<CounterButtons />
</Counter>
);
}
Hooks
- React hooks are functions that allow developers to use state and other React features in functional components.
Custom Hooks
- Custom hooks are functions that encapsulate reusable logic and can be used across multiple component
Composition
- Composition is a pattern that allows components to be composed of smaller, more focused components.
Declarative Programming
- Declarative programming is a programming style that emphasizes the use of declarative statements to describe the desired result, rather than the steps required to achieve that result.
4. Object-related Javascript Design Patterns
Class
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
sayHello() {
console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
}
}
const john = new Person('John', 30);
john.sayHello(); // Output: Hello, my name is John and I am 30 years old.
Static methods: Static methods are methods relevant to all instances of a class — not just any one instance. These methods are called on the class itself. When a method is declared as static in a JavaScript class, it means the method is attached to the class itself, rather than the instances of that class.
Non-static method: every time you create a new object, that method gets copied and attached to the new object. This means every single object gets its own copy of the method.
Static methods: not copied to each instance of the class. Instead, they exist only on the class itself and can be called directly on the class. The benefit of this is that static methods are memory efficient, as there's only ever one copy of the method no matter how many objects you create.
static exampleStaticMethod() {
console.log('This is a static method');
}
Getters and Setters: Getters and setters are used to define Object Accessors (Computed Properties). These are methods that run when getting or setting a property.
get name() {
return this.name;
}
set name(newName) {
this.name = newName;
}
// above inside the class
let john = new Person('John', 30);
console.log(john.name); // Output: John
john.age = 31;
Advantages of classes:
- A clear and explicit structure for defining objects and their behaviors, making it easier to organize code and understand the relationships between different objects.
- Encapsulation, meaning that the internal state of an object is hidden from the outside world, making it easier to maintain and modify code without affecting other parts of the system.
- Inheritance, allowing subclasses that inherit the properties and methods of their parent classes, making it easier to reuse code and build complex object hierarchies.
Disadvantages of classes:
- Classes can be more complex and harder to understand than prototypes, especially for devs with less OOP experience.
- Classes can be more rigid than prototypes because they require a predefined structure, making it harder to modify objects at runtime.
Prototype
const Person = {
init(name, age) {
this.name = name;
this.age = age;
return this;
},
sayHello() {
console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
}
};
const john = Object.create(Person).init('John', 30);
john.sayHello(); // Output: Hello, my name is John and I am 30 years old.
Advantages of prototypes:
- More flexible than classes because they allow objects to be created and modified dynamically at runtime, without the need for a predefined class structure.
- More lightweight and efficient than classes because they don't require the overhead of a class definition.
- More intuitive for many devs for simpler and more natural way of creating objects.
Disadvantages of prototypes
- Less organized and harder to maintain than classes, especially for large and complex systems.
- Less efficient than classes because they require more dynamic lookup and method resolution at runtime.
Creational
Singleton Pattern
- Ensure that only one instance of a particular object is created and provide a global point of access to that instance.
- Restricts the instantiation of a class to a single instance and provides a global point of access to that instance.
- Usage: global state management or resource sharing.
const Singleton = (function() {
let instance;
function createInstance() {
const object = new Object('I am the Singleton!');
return object;
}
return {
getInstance: function() {
if (!instance) {
instance = createInstance();
}
return instance;
}
};
})();
const instance1 = Singleton.getInstance();
const instance2 = Singleton.getInstance();
console.log(instance1 === instance2); // Output: true
Constructor Pattern
- Create new objects with their own set of properties and methods.
- Usage: create many instances of similar objects with unique values.
- Creating multiple instances of similar objects: When you need to create many instances of similar objects with unique values, you can use the constructor pattern to define a blueprint for the object and then create new instances using the new keyword.
- Encapsulating object creation, implementing inheritance, adding instance-specific behavior, factory function.
function Person(name, age) {
this.name = name;
this.age = age;
Person.count++;
}
Person.count = 0;
const john = new Person('John', 30);
const jane = new Person('Jane', 25);
console.log(john); // Output: Person { name: 'John', age: 30 }
console.log(jane); // Output: Person { name: 'Jane', age: 25 }
console.log(Person.count); // Output: 2
Factory Pattern
- Create objects without specifying the class of the object that will be created.
- Usage: simplify object creation and decouple object creation from object usage.
function createAnimal(type) {
switch (type) {
case 'cat':
return new Cat();
case 'dog':
return new Dog();
default:
return null;
}
}
class Cat {
speak() {
console.log('Meow');
}
}
class Dog {
speak() {
console.log('Woof');
}
}
const garfield = createAnimal('cat');
garfield.speak(); // Output: Meow
const odie = createAnimal('dog');
odie.speak(); // Output: Woof
Structural
Adapter Pattern
- Convert the interface of one class into another interface that the client expects.
- Usage: make existing code work with new code or to decouple the client from the implementation details of a class.
Decorator Pattern
- Add new behavior or functionality to an object at runtime.
- Usage: add features to objects without changing their interface or behavior.
Facade Pattern
- Provide a simplified interface to a complex system or set of classes.
- Usage: encapsulate complex functionality and make it easier to use.
Behavioral
Observer Pattern
- Used to notify multiple objects when the state of another object changes.
- Often used for event handling or to implement publish-subscribe systems.
Command Pattern
- Used to encapsulate a request as an object, allowing it to be passed as a parameter or stored for later use.
- Often used for undo-redo functionality or to implement transactional behavior.
Strategy Pattern
- Used to define a family of interchangeable algorithms and allow clients to choose the algorithm they want to use.
- Often used to encapsulate and swap out complex algorithms at runtime.
Related URLs
- The Comprehensive Guide to JavaScript Design Patterns: https://www.toptal.com/javascript/comprehensive-guide-javascript-design-patterns