Lesson 18-Design Patterns in Next.js

Design Patterns in Next.js

Next.js is a highly flexible framework that allows developers to adopt various design patterns to organize and build applications. Below are some common design patterns and their applications in Next.js, including detailed code examples and step-by-step analysis.

MVC (Model-View-Controller)

Although Next.js does not enforce the MVC pattern, it integrates well with it. In a Next.js application, you can place business logic in the Model layer, use page components as the View layer, and handle logic in API routes or page-level controllers as the Controller layer.

  • Model: Define data models in the /models directory.
  • View: Page components located in the /pages directory.
  • Controller: API routes located in the /pages/api directory.

Model (Data Model)

// models/post.js
export function fetchPosts() {
  return fetch('https://api.example.com/posts').then(res => res.json());
}

View (View)

// pages/index.js
import { useEffect, useState } from 'react';
import fetchPosts from '../models/post';

export default function Home() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetchPosts().then(posts => setPosts(posts));
  }, []);

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Controller (Controller)

// pages/api/posts.js
import fetchPosts from '../../models/post';

export default async function handler(req, res) {
  const posts = await fetchPosts();
  res.status(200).json(posts);
}

Redux

Redux is a popular front-end state management library that integrates well with Next.js. It is typically used for managing global state in large-scale applications.

  • Store: Create a Redux store.
  • Reducers: Define state update logic.
  • Actions: Define data update operations.
  • Connectors: Use react-redux’s connect or useSelector and useDispatch to connect components to the store.

Store

// store/store.js
import { configureStore } from '@reduxjs/toolkit';
import postsReducer from './reducers/postsReducer';

export default configureStore({
  reducer: {
    posts: postsReducer,
  },
});

Reducer

// store/reducers/postsReducer.js
import { FETCH_POSTS } from '../actions/types';

const initialState = {
  posts: [],
};

export default function postsReducer(state = initialState, action) {
  switch (action.type) {
    case FETCH_POSTS:
      return {
        ...state,
        posts: action.payload,
      };
    default:
      return state;
  }
}

Actions

// store/actions/postsActions.js
import { FETCH_POSTS } from './types';

export function fetchPosts() {
  return async dispatch => {
    const response = await fetch('https://api.example.com/posts');
    const data = await response.json();
    dispatch({ type: FETCH_POSTS, payload: data });
  };
}

Connectors

// pages/index.js
import { useSelector, useDispatch } from 'react-redux';
import { fetchPosts } from '../store/actions/postsActions';

export default function Home() {
  const posts = useSelector(state => state.posts.posts);
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(fetchPosts());
  }, [dispatch]);

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Context API

React’s Context API is a lightweight alternative to Redux for managing global state, suitable for smaller applications.

  • Provider: Create a Context.Provider to wrap the entire application.
  • Consumer: Use Context.Consumer or the useContext Hook to consume context values.
  • State Management: Define state and update functions in the Provider.

Provider

// contexts/PostsContext.js
import { createContext, useContext, useState } from 'react';

const PostsContext = createContext();

export function PostsProvider({ children }) {
  const [posts, setPosts] = useState([]);

  return (
    <PostsContext.Provider value={{ posts, setPosts }}>
      {children}
    </PostsContext.Provider>
  );
}

export function usePosts() {
  return useContext(PostsContext);
}

Consumer

// pages/index.js
import { useEffect } from 'react';
import { usePosts } from '../contexts/PostsContext';

export default function Home() {
  const { posts, setPosts } = usePosts();

  useEffect(() => {
    fetch('https://api.example.com/posts')
      .then(res => res.json())
      .then(data => setPosts(data));
  }, [setPosts]);

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Hooks

React Hooks, introduced in React 16.8, allow you to use state and other React features in function components.

  • useState: Manages component state.
  • useEffect: Handles side effects like data fetching or subscriptions.
  • useReducer: Manages complex state with a reducer function.
  • useContext: Accesses global state.

useState

// pages/index.js
import { useState, useEffect } from 'react';

export default function Home() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch('https://api.example.com/posts')
      .then(res => res.json())
      .then(data => setPosts(data));
  }, []);

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

useEffect

// pages/index.js
import { useState, useEffect } from 'react';

export default function Home() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch('https://api.example.com/posts')
      .then(res => res.json())
      .then(data => setPosts(data))
      .catch(error => console.error(error));
  }, []);

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

useReducer

// pages/index.js
import { useReducer, useEffect } from 'react';

const initialState = {
  posts: [],
  loading: true,
};

function postsReducer(state, action) {
  switch (action.type) {
    case 'FETCH_POSTS':
      return {
        ...state,
        posts: action.payload,
        loading: false,
      };
    default:
      return state;
  }
}

export default function Home() {
  const [state, dispatch] = useReducer(postsReducer, initialState);

  useEffect(() => {
    fetch('https://api.example.com/posts')
      .then(res => res.json())
      .then(data => dispatch({ type: 'FETCH_POSTS', payload: data }))
      .catch(error => console.error(error));
  }, []);

  return (
    <div>
      <h1>Posts</h1>
      {state.loading ? (
        <p>Loading...</p>
      ) : (
        <ul>
          {state.posts.map(post => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

useContext

// pages/index.js
import { useContext } from 'react';
import { usePosts } from '../contexts/PostsContext';

export default function Home() {
  const { posts } = usePosts();

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Composition vs Inheritance

In React and Next.js, composition is recommended over inheritance for building components, as it makes components more flexible and easier to maintain.

  • Composition: Use Higher-Order Components (HOCs) or custom Hooks to reuse logic.
  • Inheritance: Although not recommended, it may be used in certain cases.

Composition (Using Custom Hook)

// hooks/useFetch.js
import { useState, useEffect } from 'react';

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch(url)
      .then(response => response.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(error => {
        setError(error);
        setLoading(false);
      });
  }, [url]);

  return { data, loading, error };
}

Usage (Using Custom Hook)

// pages/index.js
import { useFetch } from '../hooks/useFetch';

export default function Home() {
  const { data, loading, error } = useFetch('https://api.example.com/posts');

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {data.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Service Layer

The Service Layer is a design pattern that encapsulates business logic, allowing components to focus on UI and interactions.

  • Services: Create a /services directory to store business logic.
  • Data Access: Use libraries like axios for data fetching.
  • Business Logic: Handle data transformation and validation.

Services

// services/postService.js
import axios from 'axios';

export async function fetchPosts() {
  const response = await axios.get('https://api.example.com/posts');
  return response.data;
}

Usage (Using Service)

// pages/index.js
import { useEffect, useState } from 'react';
import { fetchPosts } from '../services/postService';

export default function Home() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetchPosts()
      .then(data => setPosts(data))
      .catch(error => console.error(error));
  }, []);

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Layered Architecture

Divide the application into layers such as the Presentation Layer, Business Logic Layer, and Data Access Layer.

  • Presentation Layer: Page components.
  • Business Logic Layer: Business logic and service layer.
  • Data Access Layer: Data operations, such as database interactions.

Data Access Layer

// dataAccess/postDataAccess.js
import axios from 'axios';

export async function fetchPosts() {
  const response = await axios.get('https://api.example.com/posts');
  return response.data;
}

Business Logic Layer

// businessLogic/postBusinessLogic.js
import { fetchPosts } from '../dataAccess/postDataAccess';

export async function getPosts() {
  const posts = await fetchPosts();
  return posts.map(post => ({
    id: post.id,
    title: post.title.toUpperCase(),
  }));
}

Presentation Layer

// pages/index.js
import { useEffect, useState } from 'react';
import { getPosts } from '../businessLogic/postBusinessLogic';

export default function Home() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    getPosts()
      .then(data => setPosts(data))
      .catch(error => console.error(error));
  }, []);

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Domain-Driven Design (DDD)

DDD is a domain-driven design pattern that emphasizes building software around business domain concepts.

  • Entities: Define business entities.
  • Value Objects: Define immutable objects.
  • Repositories: Define data access interfaces.
  • Services: Define business logic.

Entities

// domain/entities/post.js
class Post {
  constructor(id, title) {
    this.id = id;
    this.title = title;
  }

  static fromJSON(json) {
    return new Post(json.id, json.title);
  }
}

Value Objects

// domain/valueObjects/id.js
class Id {
  constructor(value) {
    this.value = value;
  }

  toString() {
    return this.value.toString();
  }
}

Repositories

// domain/repositories/postRepository.js
import axios from 'axios';
import { Post } from '../entities/post';

export class PostRepository {
  async findAll() {
    const response = await axios.get('https://api.example.com/posts');
    return response.data.map(post => Post.fromJSON(post));
  }
}

Services

// domain/services/postService.js
import { PostRepository } from '../repositories/postRepository';

export class PostService {
  constructor(repository) {
    this.repository = repository || new PostRepository();
  }

  async getPosts() {
    const posts = await this.repository.findAll();
    return posts.map(post => ({
      id: post.id.toString(),
      title: post.title.toUpperCase(),
    }));
  }
}

Usage (Using DDD)

// pages/index.js
import { useEffect, useState } from 'react';
import { PostService } from '../domain/services/postService';

export default function Home() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    const service = new PostService();
    service.getPosts()
      .then(data => setPosts(data))
      .catch(error => console.error(error));
  }, []);

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Event Sourcing

Event Sourcing is a design pattern that records business events and reconstructs state from them.

  • Events: Define business events.
  • Event Store: Store the history of events.
  • Read Models: Build views based on events.

Events

// domain/events/postCreated.js
class PostCreated {
  constructor(id, title) {
    this.id = id;
    this.title = title;
  }
}

Event Store

// domain/eventStore.js
import { PostCreated } from './events/postCreated';

export class EventStore {
  constructor() {
    this.events = [];
  }

  add(event) {
    this.events.push(event);
  }

  findEventsFor(id) {
    return this.events.filter(event => event.id === id);
  }
}

Read Models

// domain/readModels/postReadModel.js
import { PostCreated } from '../events/postCreated';

export class PostReadModel {
  constructor(eventStore) {
    this.eventStore = eventStore;
  }

  async getPost(id) {
    const events = this.eventStore.findEventsFor(id);
    let post = null;

    for (const event of events) {
      if (event instanceof PostCreated) {
        post = {
          id: event.id,
          title: event.title,
        };
      }
    }

    return post;
  }
}

Usage (Using Event Sourcing)

// pages/index.js
import { useEffect, useState } from 'react';
import { EventStore, PostReadModel } from '../domain';

export default function Home() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    const eventStore = new EventStore();
    const readModel = new PostReadModel(eventStore);

    // Assume some events have been added to the eventStore
    // eventStore.add(new PostCreated(1, 'First Post'));
    // eventStore.add(new PostCreated(2, 'Second Post'));

    readModel.getPost(1)
      .then(post => setPosts([post]))
      .catch(error => console.error(error));
  }, []);

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Micro Frontends

Split the application into multiple small front-end modules, each deployable and scalable independently.

  • Modules: Create independent front-end modules.
  • Composition Root: Define how to compose these modules.
  • Communication: Modules communicate via events or APIs.

Modules

// modules/postModule/index.js
import { useEffect, useState } from 'react';

export default function PostModule() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch('https://api.example.com/posts')
      .then(res => res.json())
      .then(data => setPosts(data))
      .catch(error => console.error(error));
  }, []);

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Composition Root

// pages/index.js
import PostModule from '../modules/postModule';

export default function Home() {
  return (
    <div>
      <PostModule />
    </div>
  );
}

Communication

  • Event Bus: Use a global event bus for inter-module communication.
  • API Gateway: Use an API gateway as an intermediary for module communication.

Event Bus

// eventBus.js
import mitt from 'mitt';

const emitter = mitt();

export default emitter;

Module A

// modules/moduleA/index.js
import { useEffect, useState } from 'react';
import eventBus from '../eventBus';

export default function ModuleA() {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const handleEvent = (event) => {
      setMessage(event);
    };

    eventBus.on('message', handleEvent);

    return () => {
      eventBus.off('message', handleEvent);
    };
  }, []);

  return (
    <div>
      <h1>Module A</h1>
      <p>{message}</p>
    </div>
  );
}

Module B

// modules/moduleB/index.js
import { useEffect, useState } from 'react';
import eventBus from '../eventBus';

export default function ModuleB() {
  const [message, setMessage] = useState('');

  const handleMessageChange = (event) => {
    const newMessage = event.target.value;
    setMessage(newMessage);
    eventBus.emit('message', newMessage);
  };

  return (
    <div>
      <h1>Module B</h1>
      <input type="text" onChange={handleMessageChange} value={message} />
    </div>
  );
}

Usage (Using Micro Frontends)

// pages/index.js
import ModuleA from '../modules/moduleA';
import ModuleB from '../modules/moduleB';

export default function Home() {
  return (
    <div>
      <ModuleA />
      <ModuleB />
    </div>
  );
}

Serverless Functions

Leverage serverless architecture to build backend services, such as AWS Lambda or Azure Functions.

  • Lambda Function: Define serverless functions.
  • API Gateway: Configure API Gateway to trigger Lambda functions.

Lambda Function

// functions/getPosts.js
exports.handler = async (event, context) => {
  try {
    const response = await fetch('https://api.example.com/posts');
    const data = await response.json();

    return {
      statusCode: 200,
      body: JSON.stringify(data),
    };
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify({ message: error.message }),
    };
  }
};

API Gateway

Configure API Gateway in the AWS Console to trigger the above Lambda function.

Usage (Using Serverless Functions)

// pages/api/posts.js
import { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const response = await fetch('https://your-api-gateway-url/posts');
  const data = await response.json();

  res.status(200).json(data);
}
Share your love