DEV Community

Cover image for React 19 and SOLID Principles: Performance, Flexibility, and Advanced Design Strategies in Modern React Applications 🚀
abdulnasır olcan
abdulnasır olcan

Posted on

React 19 and SOLID Principles: Performance, Flexibility, and Advanced Design Strategies in Modern React Applications 🚀

We can write more modern and sustainable React code by combining the innovations and features introduced in React 19 with the SOLID principles. Now, let's explain some of the key features brought by React 19 and how a better structure can be created by integrating them with the SOLID principles, using examples.

1. Single Responsibility Principle (SRP)

SRP: A component should have only one responsibility.

a. Server Components (React 19)

Application with Server Components: In React 19, the Server Components feature was introduced, and these components work on the server-side to handle data processes. This can help us separate responsibilities such as fetching and processing data.

// Server Component that processes user information only on the server
export async function UserProfile({ userId }) {
  const user = await fetchUserData(userId);
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

// A Client Component that handles only UI
function UserProfileClient() {
  return <UserProfile userId="123" />;
}

Enter fullscreen mode Exit fullscreen mode

In this example, the UserProfile component runs on the server side and handles the data fetching process. The UserProfileClient, on the other hand, is only responsible for rendering the UI, which helps us perfectly apply the SRP.

b. Single Responsibility Principle

According to this principle, each component should only perform a single task.

// Component violating SRP
function UserProfile({ user }) {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <button onClick={() => alert('Email sent!')}>Send Email</button>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

This component both displays user information and includes a functionality for sending an email. Let's refactor it to comply with the SRP:

// Two separate components adhering to SRP
function UserProfile({ user }) {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <SendEmailButton user={user} />
    </div>
  );
}

function SendEmailButton({ user }) {
  const sendEmail = () => {
    // Email sending process
    alert(`Email sent to ${user.email}!`);
  };

  return <button onClick={sendEmail}>Send Email</button>;
}

Enter fullscreen mode Exit fullscreen mode

Here, the UserProfile component only displays user information, and SendEmailButton only handles the email sending functionality. Each component has its own single responsibility.

2. Open/Closed Principle (OCP)

OCP: Components should be open for extension but closed for modification.

a. React 19 Use Event API

The Use Event API introduced in React 19 makes event handling more flexible and extensible. In particular, it becomes easier to extend components that handle UI events and add more functionality without modifying the existing code.

// Component handling an event
function Button({ onClick, label }) {
  return <button onClick={onClick}>{label}</button>;
}

// Extension adhering to OCP
function LoggableButton({ onClick, label }) {
  const handleClick = (e) => {
    console.log('Button clicked');
    onClick(e); // Calls the original onClick
  };

  return <Button onClick={handleClick} label={label} />;
}

Enter fullscreen mode Exit fullscreen mode

Using the Use Event API in React 19, we can extend the button click functionality by adding a log, without the need to modify the existing component. This ensures that the OCP principle is maintained.

b. Open/Closed Principle

This principle states that a class (or component) should be open for extension but closed for modification. In other words, new features should be added without altering the existing functionality.

// Component violating OCP
function Notification({ type, message }) {
  if (type === 'success') {
    return <div className="success">{message}</div>;
  } else if (type === 'error') {
    return <div className="error">{message}</div>;
  } else {
    return <div className="info">{message}</div>;
  }
}

Enter fullscreen mode Exit fullscreen mode

This code violates OCP because we need to modify the component every time a new type is added. Instead, let's make it extensible.

// Extensible structure adhering to OCP
const NotificationTypes = {
  success: (message) => <div className="success">{message}</div>,
  error: (message) => <div className="error">{message}</div>,
  info: (message) => <div className="info">{message}</div>,
};

function Notification({ type, message }) {
  const NotificationComponent = NotificationTypes[type];
  return NotificationComponent ? NotificationComponent(message) : null;
}

Enter fullscreen mode Exit fullscreen mode

In this approach, we can add a new notification type by simply adding a new function to the NotificationTypes object without needing to modify the component itself.

3. Liskov Substitution Principle (LSP)

LSP: A subclass should be able to substitute for its base class.

a. React 19 Suspense for Data Fetching

The Suspense for Data Fetching feature introduced in React 19 allows us to easily switch between components while managing data fetching operations. Components can handle data fetching processes and still be seamlessly interchangeable, adhering to LSP.

1. createResource Function

This function manages asynchronous data fetching and prepares the data for rendering.

function createResource(promise) {
  let status = "pending";
  let result;

  // A suspender that watches the promise
  const suspender = promise.then(
    (res) => {
      status = "success";
      result = res;
    },
    (err) => {
      status = "error";
      result = err;
    }
  );

  return {
    read() {
      if (status === "pending") {
        throw suspender; // Triggers the Suspense mechanism to wait
      } else if (status === "error") {
        throw result; // Throws an error
      } else if (status === "success") {
        return result; // Returns the data successfully
      }
    },
  };
}

Enter fullscreen mode Exit fullscreen mode

2. Data Fetching Process

Let's write two functions that fetch data from different sources: one for user data and the other for product data.

function fetchUserData() {
  return fetch("https://jsonplaceholder.typicode.com/users/1")
    .then((response) => response.json());
}

function fetchProductData() {
  return fetch("https://fakestoreapi.com/products/1")
    .then((response) => response.json());
}

// Resource fetching user and product data
const userResource = createResource(fetchUserData());
const productResource = createResource(fetchProductData());

Enter fullscreen mode Exit fullscreen mode

3. UserProfile and ProductProfile Components

These components use Suspense to wait until the data is loaded during the data fetching process.

import React, { Suspense } from 'react';

// Simple component displaying user profile
function UserProfile({ resource }) {
  const user = resource.read(); // Fetches the data using read()
  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

// Simple component displaying product profile
function ProductProfile({ resource }) {
  const product = resource.read(); // Fetches the data using read()
  return (
    <div>
      <h1>{product.title}</h1>
      <p>Price: ${product.price}</p>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

4. Main Component with Suspense

We display fallback content while the data is loading.

function App() {
  return (
    <div>
      <h2>Profile Data</h2>
      {/* Displays "Loading..." until UserProfile data is loaded */}
      <Suspense fallback={<p>Loading user profile...</p>}>
        <UserProfile resource={userResource} />
      </Suspense>

      <h2>Product Data</h2>
      {/* Displays "Loading..." until ProductProfile data is loaded */}
      <Suspense fallback={<p>Loading product...</p>}>
        <ProductProfile resource={productResource} />
      </Suspense>
    </div>
  );
}

export default App;

Enter fullscreen mode Exit fullscreen mode

Explanation:

createResource Function: This function returns a promise (fetch) and triggers the Suspense mechanism, making the component wait until the data is ready. While the data is loading, it remains in the pending state, and once loaded, it moves to the success state.

UserProfile and ProductProfile: Both components use resource.read() to fetch data in the same way; however, one fetches user data, and the other fetches product data. This is in line with the Liskov Substitution Principle (LSP), as these components can substitute for each other.

Usage of Suspense: Suspense shows fallback content while the data is loading. You will see the message “Loading…” until either the user or product data is fully loaded.

In Summary:

  • LSP-compliant: Both UserProfile and ProductProfile fetch data using the same resource structure. Therefore, you can easily use one in place of the other.
  • Suspense: It's used to show fallback content while the data is being loaded.

By doing this, we apply the Liskov Substitution Principle from SOLID and manage more modern, asynchronous data fetching processes using React 19's Suspense feature.

b. Liskov Substitution Principle

This principle states that a subclass should be able to substitute for its base class. In other words, when you replace a component with its derived class, the application should work seamlessly.

Example: Applying this in React components generally means developing components to support the same interface. For example:

function TextInput({ value, onChange }) {
  return <input type="text" value={value} onChange={onChange} />;
}

function NumberInput({ value, onChange }) {
  return <input type="number" value={value} onChange={onChange} />;
}
Enter fullscreen mode Exit fullscreen mode

Both components use the same props, so you can swap the TextInput component with the NumberInput at any time, and the application will smoothly accept the change. In other words, these components can substitute for each other.

4. Interface Segregation Principle (ISP)

ISP: Clients should not be forced to depend on interfaces they do not use.

a. React 19 Improved Event Handling

With the Improved Event Handling feature introduced in React 19, it has become possible to control events with more granularity. To apply ISP, we can break down event handling to avoid passing unnecessary event handlers to components.

// Code violating ISP
function UserProfile({ onHover, onClick }) {
  return (
    <div onMouseEnter={onHover} onClick={onClick}>
      <h1>User Profile</h1>
    </div>
  );
}

// Functions separated in line with ISP
function UserProfile({ onClick }) {
  return (
    <div onClick={onClick}>
      <h1>User Profile</h1>
    </div>
  );
}

function HoverableProfile({ onHover }) {
  return (
    <div onMouseEnter={onHover}>
      <h1>Hoverable Profile</h1>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

In this example, each component uses only the event handler it needs. This provides a structure that aligns with the Interface Segregation Principle.

b. Interface Segregation Principle

This principle states that clients should not be forced to interact with interfaces they do not use. In React, an example of this principle is ensuring that a component is not forced to use all props in every case.

// Example violating ISP
function UserDetail({ name, age, address, email }) {
  return (
    <div>
      <h1>{name}</h1>
      <p>{age}</p>
      <p>{address}</p>
      <p>{email}</p>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Here, every prop is being used as if it's mandatory, but address and email may not be necessary in every case.

To apply ISP, we can separate unnecessary props:

function UserDetail({ name, age }) {
  return (
    <div>
      <h1>{name}</h1>
      <p>{age}</p>
    </div>
  );
}

function ContactDetail({ address, email }) {
  return (
    <div>
      {address && <p>{address}</p>}
      {email && <p>{email}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In this way, the client can use only the component they need and doesn't have to deal with unnecessary props.

5. Dependency Inversion Principle (DIP)

DIP: High-level modules should not depend on low-level modules. Both should depend on abstractions.

a. React 19 Context API Improvements

With the improvements made to the Context API in React 19, we can minimize inter-component dependency by injecting dependencies from top to bottom.

// Component directly dependent on context
function UserProfile() {
  const user = useContext(UserContext); // Context is directly used here
  return <div>{user.name}</div>;
}

// Applying DIP by using an abstraction layer
function UserProfile({ userService }) {
  const user = userService.getUser();
  return <div>{user.name}</div>;
}

// Abstraction layer
const userService = {
  getUser: () => useContext(UserContext),
};

// Usage
<UserProfile userService={userService} />

Enter fullscreen mode Exit fullscreen mode

In this example, the UserProfile component is no longer directly dependent on UserContext and is now dependent on an abstract service layer. This helps us maintain the DIP principle.

b. Dependency Inversion Principle

This principle states that high-level modules should depend on abstractions, not on low-level modules.

Example: In React, we can apply this principle by using dependency injection or an abstraction layer to manage dependencies (e.g., API requests) instead of directly using them within components.

// Component directly dependent on API call
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`https://api.example.com/users/${userId}`)
      .then((response) => response.json())
      .then((data) => setUser(data));
  }, [userId]);

  return user ? <div>{user.name}</div> : <p>Loading...</p>;
}

Enter fullscreen mode Exit fullscreen mode

This example violates DIP because the UserProfile component is directly dependent on the API. Let's solve this by abstracting it:

// Component applying DIP by creating an abstraction layer
function UserProfile({ userId, userService }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    userService.getUser(userId).then((data) => setUser(data));
  }, [userId, userService]);

  return user ? <div>{user.name}</div> : <p>Loading...</p>;
}

// UserService abstraction layer
const userService = {
  getUser: (userId) => fetch(`https://api.example.com/users/${userId}`).then((res) => res.json()),
};

// Usage
<UserProfile userId="123" userService={userService} />;

Enter fullscreen mode Exit fullscreen mode

Here, UserProfile is no longer directly dependent on the API; instead, it uses the userService abstraction. This allows userService to be easily tested and replaced.

Conclusion:

The features introduced in React 19 provide a great foundation for applying SOLID principles. Innovations like Server Components, Suspense for Data Fetching, and Improved Event Handling contribute to making the code more flexible, modular, and sustainable. By adhering to SOLID principles in React projects, you can achieve performance and flexibility while leveraging modern features.

Happy coding! 🚀

Top comments (0)