Practical advice for writing React Components
Joe Greve
Posted on June 1, 2024
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
andtwMerge
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>
);
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>
);
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;
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>
);
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>
);
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;
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;
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>
);
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>
);
By letting the parent List
component handle the spacing between ListItem
s, 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>
);
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
- Install the necessary packages:
npm install -D prettier prettier-plugin-tailwindcss
- Update your
.prettierrc
file:
{
"plugins": ["prettier-plugin-tailwindcss"]
}
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!
Posted on June 1, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.