All articlesSoftware Design

15 React Component Principles & Best Practices for Better Software Architecture & Design

The conscious road to more scalable designs of React applications.

Petar IvanovPetar Ivanov
12 min read
On this page

Intro

I’ve been using React since 2018, and since then, I’ve been talking with many colleagues about a set of good practices and principles for building React Applications. I’m planning to curate a series of articles that consist of a set of React principles and best practices for better software architecture and design. In the first article of the series, I’m focusing on React Components.

I’m bringing my curiosity and experience in Software Design & Architecture into building well-designed, maintainable, and scalable React applications.

The series of articles aims to bridge the gap between React beginners and those growing as React experts and engineers.

Caution: This is not a beginner’s guide, so most of the shared concepts require some React knowledge.

If you find yourself in that situation, refresh your fundamentals and continue.

As with every piece of advice and information on the Internet, don’t go to extremes without questioning it.

💡 Take everything as an opinion. Software can be built in multiple ways.

In this article, we will go through:

  • Function Components vs. Class Components
  • Name of Components
  • Helper Functions
  • Repetitive Markup
  • Component’s Size and Length
  • Props
  • Ternary Operators
  • Lists Mapping
  • Hooks vs. HOCs and Render Props
  • Custom Hooks
  • Render Functions
  • Error Boundaries
  • Suspense

1. Favor Function Components over Class Components

Class components can be verbose and harder to manage. Function components are simpler and easier to understand. With function components, you have better readability. The things you have to remember and think about are a lot smaller compared to class components’s state management, lifecycle methods, etc.

The only exception for using Class Components is when you want to use Error Boundaries.

⛔ Avoid using class components

TypeScript
class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }
  
  increment() {
    this.setState(state => ({
      count: state.count + 1
    }));
  }

  return (
    <div>
      Count: {this.state.count}
      <button onClick={() => this.increment()}>Increment</button>
    </div>
  );
}

✅ Preferusing function components

TSX
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      Count: {count}
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

2. Name your Components

Nameless components can make debugging difficult and reduce code readability. Named components improve the stack trace and make your codebase easier to navigate, manage, and understand. You can navigate between errors much easier when you use named components.

⛔ Avoid using nameless components

TypeScript
export default () => <div>Details</div>;

✅ Prefer naming your components

TypeScript
export default function UserDetails() {
  return <div>User Details</div>;
}

3. Move Helper Functions Outside of Components

Nesting helper functions inside components can clutter the component and make it harder to read. Keeping helper functions outside the components improves readability and separates concerns.

⛔ Avoid nesting your helper functions inside your components if closure is not needed

TypeScript
function UserProfile({ user }) {
  function formatDate(date) {
    return date.toLocaleDateString();
  }
  return <div>Joined: {formatDate(user.joinDate)}</div>;
}

✅ Prefer moving these helper functions outside the component (before it, so you can read the file from top to bottom)

TypeScript
function formatDate(date) {
  return date.toLocaleDateString();
}

function UserProfile({ user }) {
  return <div>Joined: {formatDate(user.joinDate)}</div>;
}

4. Extract Repetitive Markup with Config Objects

Hardcoding repetitive markup makes the code harder to maintain and update. Extracting repetitive markup with maps/loops and configuration objects makes the code more maintainable and readable. It simplifies updates and additions, as changes only need to be made in one place (inside the configuration object).

⛔ Avoid hardcoding repetitive markup

TypeScript
function ProductList() {
  return (
    <div>
      <div>
        <h2>Product 1</h2>
        <p>Price: $10</p>
      </div>
      <div>
        <h2>Product 2</h2>
        <p>Price: $20</p>
      </div>
      <div>
        <h2>Product 3</h2>
        <p>Price: $30</p>
      </div>
    </div>
  );
}

✅ Prefer extracting repetitive markup with configuration objects and loops

TypeScript
const products = [
  { id: 1, name: 'Product 1', price: 10 },
  { id: 2, name: 'Product 2', price: 20 },
  { id: 3, name: 'Product 3', price: 30 }
];
  
function ProductList() {
  return (
    <div>
      {products.map(product => (
        <div key={product.id}>
          <h2>{product.name}</h2>
          <p>Price: ${product.price}</p>
        </div>
      ))}
    </div>
  );
}

5. Manage the Component’s Size

Large and lengthy components can be difficult to understand, maintain, and test. Smaller and more focused components are easier to read, test, and maintain. Each component has a single responsibility, reason to change and rerender, making the codebase more modular and easier to manage.

⛔ Avoid big and nasty components

TypeScript
function UserProfile({ user }) {
  return (
    <div>
      <div>
        <img src={user.avatar} alt={`${user.name}'s avatar`} />
        <h2>{user.name}</h2>
      </d
      <div>
        <h3>Contact</h3>
        <p>Email: {user.email}</p>
        <p>Phone: {user.phone}</p>
      </div>
    </div>
  );
}

✅ Prefer small and easy-to-read components

TSX
function UserProfile({ user }) {
  return (
    <div>
      <ProfileHeader avatar={user.avatar} name={user.name} /
      <ProfileContact email={user.email} phone={user.phone} />
    </div>
  );
}

6. Destructure Props

Repeating props can make the component harder to read and maintain. Destructuring the props improves readability and makes the code more consious. It reduces repetition and makes it clear which props are being used.

⛔ Avoid repeating props everywhere in your component

TypeScript
function UserProfile(props) {
  return (
    <>
      <div>Name: {props.name}</div>
      <div>Email: {props.email}</div>
    </>
  );
}

✅ Prefer destructuring your props

TypeScript
function UserProfile({ name, email }) {
  return (
    <>
      <div>Name: {name}</div>
      <div>Email: {email}</div>
    </>
  );
}

7. Manage the Number of Props

Having too many props can make a component complex and harder to understand. Fewer props makes a component easier to use and understand.

Most of the time, when we have a component with > 5 props, it’s a sign that it can be splitted. But that’s not a hard rule to follow because as an “okay” example the input field has many props but it’s not needed to be splitted.

When we have < 5 props, it’s a sign that something can be extracted. Maybe we have too much data into a single component.

Less props ⇒ less reasons to change and rerender.

⛔ Avoid using many props (maybe > 5, you should split it, but not always, ex: input)

TypeScript
function UserProfile({ 
  name, email, avatarUrl, address, paymentProfiles 
}) {
  return (
    <div>
      <img src={avatarUrl} alt={`${name}'s avatar`} />
      <h1>{name}</h1>
      <p>Email: {email}</p>
      <p>Address: {address}</p>
      <ul>
        {paymentProfiles.map(paymentProfile => (
          <li key={paymentProfile.id}>
	    <h2>{paymentProfile.cardNumber}</h2>
	    <p>{paymentProfile.cardName}</p>
	  </li>
        ))}
      </ul>
    </div>
  );
}

✅ Prefer using small number of props (maybe < 5)

TSX
function UserProfile({ user }) {
  return (
    <Info name={user.name} email={user.email} avatarUrl={user.avatarUrl} />
    <Address address={user.address} />
    <PaymentProfiles paymentProfiles={user.paymentProfiles} />
  );
}

8. Props - Objects vs. Primitives

Passing many primitives can clutter the component and make it harder to manage related data. Grouping related props into an object simplifies the component interface and improves readability. It makes the code cleaner and easier to understand by logically grouping related data.

⛔ Avoid passing primitives when props are somehow related

TypeScript
function Address({ street, city, state, zip }) {
  return (
    <div>
      <p>Street: {street}</p>
      <p>City: {city}</p>
      <p>State: {state}</p>
      <p>ZIP: {zip}</p>
    </div>
  );
}

✅ Prefer passing an object, grouping the props

TypeScript
function Address({ address }) {
  const { street, city, state, zip } = address;

  return (
    <div>
      <p>Street: {street}</p>
      <p>City: {city}</p>
      <p>State: {state}</p>
      <p>ZIP: {zip}</p>
    </div>
  );
}

9. Manage Ternary Operators

Nested ternary operators can make the code difficult to read and maintain. Clear if-else statements enhance hode readability and maintainability. They make the control flow easier to understand and debug.

⛔ Avoid nested or multiple ternary operators - hard to read and follow

TypeScript
function Greeting({ isLoggedIn, age }) {
  return (
    <div>
      {isLoggedIn ? (
       age > 18 ? (
          "Welcome back!"
        ) : (
          "You are underaged!"
        )
      ) : (
        "Please log in."
      )}
    </div>
  );
}

✅ Prefer if-else blocks and explicit return statements in your component

TypeScript
function Greeting({ isLoggedIn, age }) {
  if (!isLoggedIn) {
    return <div>Please log in.</div>;
  }

  if (age > 18) {
    return <div>Welcome back!</div>;
  }

  return <div>You are underaged!</div>;
}

10. Abstract Lists Mapping into Separate Components

Directly mapping over lists in the return statement can make the component cluttered and harder to read. Separating the map operation from the main component into individual component from makes the code cleaner and easier to read. The main component’s boilerplate becomes simpler. It separates the rendering logic from the component’s main structure, enhancing readability.

The main component doesn’t care about the details.

⛔ Avoid using map function over a list inside your component

TypeScript
function PaymentProfilesPage({ paymentProfiles }) {
  return (
    <h1>Payment Profiles:</h1>
    <ul>
      {paymentProfiles.map(paymentProfile => (
        <li key={paymentProfile.id}>
          <h2>{paymentProfile.cardNumber}</h2>
          <p>{paymentProfile.cardName}</p>
        </li>
      ))}
    </ul>
  );
}

✅ Prefer moving the map function outside the component - easy to read. The main component doesn’t care about the details.

TSX
function PaymentProfilesPage({ paymentProfiles }) {
  return (
    <h1>Payment Profiles:</h1>
    <PaymentProfilesList paymentProfiles={paymentProfiles} />
  );
}

11. Favor Hooks over HOCs and Render Props

HOCs (Higher-Order Components) and render props patterns have been traditionally used for sharing logic and behavior across components. However, these patterns can lead to complex and deeply nested component trees, making the code harder to read, debug, and maintain. Hooks offer a more straightforward and declarative approach to encapsulating and reusing logic within functional components.

Hooks offer a more simpler mental model - we compose a set of functions to access external logic and behavior, making the overall JSX template simpler to read and understand.

⛔ Avoid using HOCs and render props

TSX
function UserProfileForm() {
  return (
    <Form>
      {({ values, handleChange }) => (
        <input
          value={values.name}
          onChange={e => handleChange('name', e.target.value)}
        />
        <input
          value={values.password}
          onChange={e => handleChange('password', e.target.value)}
        />
      )}
    </Form>
  );
}

✅ Prefer using hooks

TSX
function UserProfileForm() {
  const { values, handleChange } = useForm();

  return (
    <Form>
      <input
        value={values.name}
	onChange={e => handleChange('name', e.target.value)}
      />
      <input
        value={values.password}
	onChange={e => handleChange('password', e.target.value)}
      />
    </Form>
  );
}

12. Reuse and Encapsulate Logic with Custom Hooks

Duplicating logic leads to code redundancy and makes maintaince harder. Custom hooks allow for code reuse, making components cleaner and more maintainable. With custom hooks, the logic is encapsulated, reducing duplication and improving readability. It also makes its testing much easier.

⛔ Avoid duplicating logic across components

TSX
function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch('/api/users')
      .then(response => response.json())
      .then(data => setUsers(data));
  }, []);

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

function ProductList() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch('/api/products')
      .then(response => response.json())
      .then(data => setProducts(data));
  }, []);

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

✅ Prefer encapsulating and reusing logic with custom hooks

TSX
function useFetch(url) {
  const [data, setData] = useState([]);

  useEffect(() => {
    fetch(url)
      .then(response => response.json())
      .then(data => setData(data));
  }, [url]);

  return data;
}

function UserList() {
  const users = useFetch('/api/users');

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

function ProductList() {
  const products = useFetch('/api/products');

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

13. Extract Render Functions

Nesting complex render functions inside components can clutter the component and make it harder to read, test, and maintain. Defining render functions outside the component or using separate components improvemes readability and maintainability. It keeps the main component clean and focuses on its primary purpose.

⛔ Avoid nesting your render functions inside your components

TypeScript
function UserProfile({ user }) {
  function renderProfileDetails() {
    return <div>{user.name} - {user.age}</div>;
  }

  return <div>{renderProfileDetails()}</div>;
}

✅ Prefer extracting your render functions outside your components - on top of them or separate components.

TSX
function renderProfileDetails(user) {
  return <div>{user.name} - {user.age}</div>;
}

// OR using a separate component
function ProfileDetails({ user }) {
  return <div>{user.name} - {user.age}</div>;
}

function UserProfile({ user }) {
  return (
    <div>
      {renderProfileDetails(user)}
      // OR
      <ProfileDetails user={user} />;
    </div>
  );
}

14. Use Error Boundaries

Unhandled errors can crash the whole application, affecting the user experience. Error Boundaries allow you to catch and handle errors gracefully, improving the application’s resilience. This ensures a better user experience by displaying fallback UIs, instead of crashing the entire app.

⛔ Avoid allowing errors in child components to crash the entire application

TSX
function App() {
  return <UserProfile />;
}

✅ Prefer using error boundaries to catch and handle errors in child component trees

TSX
function App() {
  return (
    <ErrorBoundary>
      <UserProfile />
    </ErrorBoundary>
  );
}

15. Use Suspense

Manually managing loading states can be repetitive and error-prone. Suspense simplifies the handling of async operations by providing a declarative way to manage loading states. This reduces boilerplate code and makes the component logic cleaner.

⛔ Avoid manually handling loading states for async operations

TSX
function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/user')
      .then(response => response.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, []);

  if (loading) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </div>
  );
}

✅ Prefer using Suspense to handle async operations and loading states gracefully

TSX
import { UserProfile } from './UserProfile';

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile />
    </Suspense>
  );
}

TL;DR

  1. Favor Function Components over Class Components
  2. Name your Components
  3. Move Helper Functions Outside of Components
  4. Extract Repetitive Markup with Config Objects
  5. Manage the Component’s Size
  6. Destructure Props
  7. Manage the Number of Props
  8. Props - Objects vs. Primitives
  9. Manage Ternary Operators
  10. Abstract Lists Mapping into Separate Components
  11. Favor Hooks over HOCs and Render Props
  12. Reuse and Encapsulate Logic with Custom Hooks
  13. Extract Render Functions
  14. Use Error Boundaries
  15. Use Suspense

Conclusion

In upcoming articles, we will dive deeper into other crucial areas of React development, including state management, testing, application’s organization, and much more. Stay tuned to continue improving your skills and building better React apps.

Related articles

Whenever you’re ready, here’s how I can help you:

  1. 1.

    The Conscious React: React architecture, design & clean code — 100+ production tips across 6 chapters, updated for React 19, plus 4 companion repos you can clone and run.

  2. 2.

    The Conscious Node: Node.js architecture, design & clean code — 157 production tips across 10 chapters, from module boundaries to the transactional outbox and zero-downtime deploys.

  3. 3.

    The JavaScript Architect Bundle: Both books + all React companion repos + CLAUDE.md rulesets + both playbooks. The complete path from developer to architect.

  4. 4.

    Free Resources: Architecture playbooks, cheat-sheets, and the JavaScript Architect Roadmap — practical guides for leveling up to senior.

The T-Shaped Dev

Join 30K+ engineers leveling up to architect

One practical tip on JavaScript, React, Node.js, and software architecture every week. No spam, unsubscribe anytime.

Petar Ivanov

Written by

Petar Ivanov

Software engineer, author, and speaker. I help JavaScript developers grow from Mid → Senior → Architect — production-grade React, Node.js, and AI systems.