DEV Community

Cover image for Practical advice for writing React Components
Joe Greve
Joe Greve

Posted on

Practical advice for writing React Components

I've been spending a lot of time with React lately - and I've developed some opinions. Disagree with any of them? Ping me at @greveJoe on the social media platform formerly known as Twitter.

Let's get into it.

1. Combine Classes with clsx and twMerge

Blind concatenation of classNames is a trap, especially with Tailwind. Half the time it won't work how you expect because of CSS specificity. Instead, using clsx (or similar) and twMerge together provides flexibility when combining classes - letting you safely override default component styles with your own.

Why This Matters

  • Cleaner Code: clsx and twMerge help you write concise and readable class combinations.
  • Conditional Classes: clsx makes it easy to apply classes based on props or state.
  • Conflict Resolution: twMerge allows you to override conflicting classes, ensuring the desired styles are applied.

Example: Custom Button Component

Don't do this:

const MyButton = ({ isActive, children }) => (
  <button className={`bg-blue-500 text-white px-4 py-2 ${isActive ? 'border-2 border-red-500' : ''}`}>
    {children}
  </button>
);
Enter fullscreen mode Exit fullscreen mode

Why this is bad: Concatenating classes directly can lead to hard-to-read and error-prone code, especially as the number of conditional classes increases.

Do this instead:

import clsx from 'clsx';
import { twMerge } from 'tailwind-merge';

const cn = (...classes) => twMerge(clsx(...classes));

const MyButton = ({ isActive, children }) => (
  <button className={cn(
    'bg-blue-500 text-white px-4 py-2',
    isActive && 'border-2 border-red-500'
  )}>
    {children}
  </button>
);
Enter fullscreen mode Exit fullscreen mode

Creating a Utility Function

To make combining classes even more convenient, create a utility function that combines clsx and twMerge:

import clsx from 'clsx';
import { twMerge } from 'tailwind-merge';

const cn = (...classes) => twMerge(clsx(...classes));

export default cn;
Enter fullscreen mode Exit fullscreen mode

You can then import and use this cn function in your components:

import cn from './utils/cn';

const MyComponent = ({ isActive }) => (
  <div className={cn('bg-white p-4', isActive && 'border-2 border-blue-500')}>
    {/* ... */}
  </div>
);
Enter fullscreen mode Exit fullscreen mode

By using clsx and twMerge together, you can create cleaner, more maintainable code when working with utility-first CSS frameworks like Tailwind CSS. This approach makes it easier to apply conditional classes, resolve conflicts, and keep your components readable as your application grows in complexity.

2. Embrace Default Props

When building reusable components, it's crucial to allow for default props like className or onClick. This practice makes your components more composable, flexible, and easier to integrate into different contexts. It ensures that other developers can use your custom components just like native HTML elements, without needing to learn their inner workings.

In React, forwardRef is a powerful tool that allows your custom components to behave like native HTML elements. It enables your component to receive a ref prop, which can be used to access the underlying DOM element directly.

Why This Matters

Allowing default props:

  • Enhances Flexibility: Developers can style your component or attach event handlers without modifying its internals.
  • Improves Composability: Your component can be seamlessly used within different layouts and contexts.
  • Reduces Learning Curve: Other developers can use familiar props, making your component easier to adopt.

Example: Custom Button Component

Don't do this:

const MyButton = ({ children }) => (
  <button className="bg-blue-500 text-white px-4 py-2">
    {children}
  </button>
);
Enter fullscreen mode Exit fullscreen mode

Why this is bad: This approach hardcodes styles and doesn't allow for additional props like className or onClick, making the component inflexible and hard to reuse.

Do this instead:

import React, { forwardRef } from 'react';
import clsx from 'clsx';
import { twMerge } from 'tailwind-merge';

const cn = (...classes) => twMerge(clsx(...classes));

const MyButton = forwardRef(({ children, className, ...props }, ref) => (
  <button 
    ref={ref}
    className={cn('bg-blue-500 text-white px-4 py-2', className)}
    {...props}
  >
    {children}
  </button>
));

export default MyButton;
Enter fullscreen mode Exit fullscreen mode

Using the Custom Button

Here's how you might use this MyButton component in a parent component:

import React, { useRef } from 'react';
import MyButton from './MyButton';

const ParentComponent = () => {
  const buttonRef = useRef(null);

  const handleClick = () => {
    if (buttonRef.current) {
      console.log('Button clicked!', buttonRef.current);
    }
  };

  return (
    <div>
      <MyButton ref={buttonRef} className="extra-class" onClick={handleClick}>
        Click Me
      </MyButton>
    </div>
  );
};

export default ParentComponent;
Enter fullscreen mode Exit fullscreen mode

By embracing default props and using forwardRef, you create components that are flexible, reusable, and behave like native HTML elements. This approach reduces the learning curve for other developers and makes your components easier to work with in various contexts.

Here are points 3-5 and the conclusion of the blog post:

3. Let Parents Handle Layout and Spacing

When building reusable components, it's important to let the parent component handle layout and spacing concerns. This separates the responsibilities of the parent and child components, promoting a more modular and reusable design. A more controversial way to phrase this is: Never use margin - padding and gap are all you need.

Why This Matters

  • Modularity: Keeping layout and spacing concerns in the parent component makes the child components more modular and reusable.
  • Flexibility: Parent components can adapt the layout and spacing based on their specific needs.
  • Consistency: Centralizing layout and spacing decisions in the parent component ensures a consistent user interface.

Example: List Component

Don't do this:

const ListItem = ({ children }) => (
  <div className="mb-4">
    {children}
  </div>
);

const List = () => (
  <div>
    <ListItem>Item 1</ListItem>
    <ListItem>Item 2</ListItem>
    <ListItem>Item 3</ListItem>
  </div>
);
Enter fullscreen mode Exit fullscreen mode

Why this is bad: Each ListItem has a hard-coded margin-bottom, which can lead to inconsistent spacing and make the component less adaptable.

Do this instead:

const ListItem = ({ children }) => (
  <div>{children}</div>
);

const List = () => (
  <div className="flex flex-col gap-4">
    <ListItem>Item 1</ListItem>
    <ListItem>Item 2</ListItem>
    <ListItem>Item 3</ListItem>
  </div>
);
Enter fullscreen mode Exit fullscreen mode

By letting the parent List component handle the spacing between ListItems, you create a more modular and flexible design that can easily adapt to different layout requirements.

4. Use Controlled Components for Complex Interactions

For complex interactions like forms or modals, use controlled components to manage state and provide a clear way for the parent component to control the child component's behavior.

Why This Matters

  • Predictability: Controlled components make the state and behavior of the child component more predictable.
  • Easier Testing: With controlled components, it's easier to write unit tests for the parent component.
  • Better Separation of Concerns: The parent component manages the state, while the child component focuses on rendering and user interactions.

Example: Modal Component

const Modal = ({ isOpen, onClose, children }) => (
  <div className={cn('fixed z-10 inset-0 overflow-y-auto', isOpen ? 'block' : 'hidden')}>
    <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
      {/* Modal content */}
      <div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  </div>
);
Enter fullscreen mode Exit fullscreen mode

In this example, the Modal component is controlled by the parent component through the isOpen and onClose props. This makes the modal's state predictable and easier to manage.

5. Automatically Order Tailwind Classes with Prettier

To make your Tailwind classes more consistent and easier to read, use the prettier-plugin-tailwindcss plugin to automatically order your classes.

Why This Matters

  • Consistency: Automatically ordering your Tailwind classes ensures a consistent style throughout your codebase.
  • Readability: Ordered classes are easier to scan and understand at a glance.
  • Maintainability: Consistently ordered classes make it easier for other developers to work with your code.

Example: Setting Up Prettier with Tailwind CSS

  1. Install the necessary packages:
npm install -D prettier prettier-plugin-tailwindcss
Enter fullscreen mode Exit fullscreen mode
  1. Update your .prettierrc file:
{
  "plugins": ["prettier-plugin-tailwindcss"]
}
Enter fullscreen mode Exit fullscreen mode

Now, whenever you run Prettier on your codebase, your Tailwind classes will be automatically ordered, improving the consistency and readability of your code.

Conclusion

Remember, these practices are not hard-and-fast rules but rather guidelines based on my experience. Feel free to adapt them to your specific needs and preferences.

If you have any other tips or best practices for building React components, I'd love to hear them! Connect with me on Twitter at @greveJoe and let's continue the conversation.

Happy coding!

Top comments (0)