DEV Community

Cover image for React Pattern - Build Better Component with Compound component
⚡Priyang⚡
⚡Priyang⚡

Posted on

React Pattern - Build Better Component with Compound component

React is a popular JavaScript library for building user interfaces, and one of the key concepts in React is the ability to create reusable components.

Build Better Component with One pattern that can help with building reusable components is the "Compound Component" pattern. i am sure you have heard of this term before and i am going to explain this to you now.

In the Compound Component pattern, a parent component passes data and behaviour down to a group of child components through props. The child components then use this data and behaviour to render themselves, and can also pass data back up to the parent through event handlers.

Think of compound components like the _<select>_ and _<option>_ elements in HTML. Apart they don’t do too much, but together they allow you to create the complete experience.  — Kent C. Dodds

we have two ways of creating the component

  1. React.cloneElement
  2. useContext
import { useState } from 'react';

const FormControl = ({ label, children }) => {
  const [value, setValue] = useState('');
  const [touched, setTouched] = useState(false);
  const [valid, setValid] = useState(false);
  const [errorMessage, setErrorMessage] = useState('');

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    if (event.target.value.length > 0) {
      setValid(true);
      setValue(event.target.value);
      setErrorMessage('');
    } else {
      setValid(false);
      setErrorMessage('Value is required');
    }
  };

  const handleBlur = () => {
    setTouched(true);
  };

  return (
    <div>
      {label && <Label>{label}</Label>}
      {React.cloneElement(children, {
        value,
        onChange: handleChange,
        onBlur: handleBlur,
      })}
      {touched && !valid && <ErrorMessage>{errorMessage}</ErrorMessage>}
    </div>
  );
};

const Label = ({ children }: React.ComponentPropsWithoutRef<'label'>) => {
  return <label>{children}</label>;
};

const ErrorMessage = ({ children }: React.ComponentPropsWithoutRef<'div'>) => {
  return <div className="error">{children}</div>;
};

const TextInput = ({
  value,
  onChange,
  onBlur,
}: {
  value: string;
  onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
  onBlur: (event: React.FocusEvent<HTMLInputElement>) => void;
}) => {
  return <input type="text" value={value} onChange={onChange} onBlur={onBlur} />
}

const Form = () => {
  return (
    <form>
      <FormControl label="Name">
        <TextInput />
      </FormControl>
      <FormControl label="Email">
        <TextInput />
      </FormControl>
      <button type="submit">Submit</button>
    </form>
  );
};

Enter fullscreen mode Exit fullscreen mode

this example, the FormControl component is the parent, and the TextInput component is the child. The FormControl component uses the useState hook to manage its own state values for the input value, touched state, valid state, and error message.

The FormControl component also has separate components for the label and the error message, which allows you to customize the appearance of these elements separately.

The TextInput component receives the value, onChange, and onBlur props from the FormControl component, which allows it to update the form state values and trigger the validation logic when the input value changes or when the input is blurred.

The FormControl component also renders a label and an error message based on the state values. If the input has been touched and is not valid, it will display an error message.

This compound component pattern allows the FormControl component to handle the common logic for all form inputs, such as validation and error handling, while also allowing developers to customize the specific input element with the TextInput component.

Problems

Now it's does the job that we like to do but the problem with this will changing the behavior of our sub-components.

  • if we like to pass diffrent value to the label we don't have access to that or the error div. one option is that pass the label as React Component or as i will explain in next section in useContext.
    <FormControl label="Name">// <- we don't have access to label 
        <TextInput />
    </FormControl>
Enter fullscreen mode Exit fullscreen mode
  • if we pass a two component in the FormControl than both will get the all the props
    <FormControl label="Name">
        <TextInput />
        <OtherComponent/> // <- this will also receive the props which can lead to bugs
    </FormControl>
Enter fullscreen mode Exit fullscreen mode
  • If I pass Wrap around the <TextInput /> than it will not get the onChange or onBlur Function so we need to prop drill or do the same as React.cloneElement to receive props which increases the complexity.
    <FormControl label="Name">
        <Wrapper> // <- this will receive the props we will need prop drill
            <TextInput />
        </Wrapper>
    </FormControl>
Enter fullscreen mode Exit fullscreen mode

You can look into Official React-beta docs to learn about the alternatives to cloneElement - Link

useContext()

import React, { createContext, useState, useContext } from 'react';

// Create a context object
const FormControlContext = createContext<{
   touched: boolean;
   value: string;
   handleBlur: (event: React.FocusEvent<HTMLInputElement>) => void;
   handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
   valid: boolean;
   errorMessage: string;
}>({
   touched: false,
   value: '',
   handleBlur: () => {},
   handleChange: () => {},
   valid: false,
   errorMessage: '',
});

interface FormControlProps {
   CustomHandleChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
   CustomHandleBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
   IsInvalid?: boolean;
   children: React.ReactNode;
}

const FormControl = ({
   CustomHandleChange,
   CustomHandleBlur,
   IsInvalid = true,
   children,
}: FormControlProps) => {
   const [value, setValue] = useState('');
   const [touched, setTouched] = useState(false);
   const [valid, setValid] = useState(IsInvalid);
   const [errorMessage, setErrorMessage] = useState('');

   const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
      if (event.target.value.length > 0) {
         setValid(true);
         setValue(event.target.value);
         setErrorMessage('');
      } else {
         setValid(false);
         setErrorMessage('Value is required');
      }
   };

   const handleBlur = () => {
      setTouched(true);
   };

   return (
      <FormControlContext.Provider
         value={{
            touched,
            value,
            handleBlur: CustomHandleBlur ? CustomHandleBlur : handleBlur,
            handleChange: CustomHandleChange
               ? CustomHandleChange
               : handleChange,
            valid,
            errorMessage,
         }}
      >
         {children}
      </FormControlContext.Provider>
   );
};

const Label = ({ children }: React.ComponentPropsWithoutRef<'label'>) => {
   const { touched } = useContext(FormControlContext);
   return (
      <label
         style={{
            fontWeight: touched ? 'bolder' : 'lighter',
         }}
      >
         {children}
      </label>
   );
};

const ErrorMessage = ({
   CustomError,
   children,
}: React.ComponentPropsWithoutRef<'div'> & { CustomError?: string }) => {
   const { touched, valid, errorMessage } = useContext(FormControlContext);
   if (!touched && valid) {
      return (
         <>
            <div className="error">
               <div className="message">
                  {CustomError ? CustomError : errorMessage}
               </div>
            </div>
            {children}
         </>
      );
   }
   return null;
};

const TextInput = ({ ...props }: React.ComponentPropsWithoutRef<'input'>) => {
   const { handleBlur, handleChange, value } = useContext(FormControlContext);
   return (
      <input
         type="text"
         value={value}
         onChange={handleChange}
         onBlur={handleBlur}
         {...props}
      />
   );
};

const Form = () => {
   return (
      <form>
         <FormControl>
            <Label />
            <TextInput />
            <ErrorMessage />
         </FormControl>
         <FormControl>
            <Label />
            <TextInput />
            <ErrorMessage />
         </FormControl>
         <button type="submit">Submit</button>
      </form>
   );
};

Enter fullscreen mode Exit fullscreen mode

In this example, the FormControl component is the parent, and the child components are the ones that will consume the context. i know it got little bigger than the previus one but we got way more control over our components.

Also we have added validation and custom handlers. The resone we could not have added in the previus because it will lead to bugs as i mention in the problem section.

Explanation -

FormControl will pass the context values and function all the childrens without prop drilling. Label,

The Label component is a simple wrapper around a label element that receives its children as props. It uses the touched state from the FormControl context to style the label differently when the form control has been touched by the user.

The TextInput component is a simple wrapper around an input element that receives all other props as an object. It uses the handleBlur, handleChange, and value states from the FormControl context to manage the value and event handlers for the input element.

The ErrorMessage component is a wrapper around a div element that receives its children as props. It uses the touched, valid, and errorMessage states from the FormControl context to render an error message when the form control has been touched and is currently invalid. It also accepts a CustomError prop that can be used to override the default error message.

Using the useContext() hook will prevent the problem caused by React.cloneElement.

That's it

That's Pretty Much Gif

Hope you enjoy the reading and if you want to have something to correct or feedback or just want to ask for help you can reach out to me on Twitter or linkedin though i personally prefer Twitter.

Top comments (0)