Engineering

Prop drilling and how to avoid it

Prop drilling

Prop drilling is the wonderful practice of passing props through levels of components. Some prop drilling is good. Most prop drilling is evil.

You have a hierarchical component tree. You keep state where it makes sense to manage it. You pass state changes one way down the tree. This is good. You encounter a case where component state is needed several layers below where it’s defined and managed. You need the same data from the backed in two places at once. Less good.

Consider the following:

const ToggleLabel: FC<{label: string}> = ({ label }) => <span>{label}</span>;

const ToggleButton: FC<{
	value: string,
	onChange: (val: SyntheticEvent) => void,
}> = ({ value, onChange }) => (
    <input value={value} onChange={onChange} type="checkbox" />
);

const Toggle: FC<ToggleProps> = ({ value, onChange, label }) => (
    <>
        <ToggleLabel label={label} />
        <ToggleButton value={value} onChange={onChange} />
    </>
);

This is fine. Trust me.

This is not fine:

const UserForm: FC<UserFormProps> = ({ user, onSubmit }) => {
    const [userState, setUserState] = useState({ number: null, email: null });

    const handleStateChange = (field: string, val: any) => {
        setUserState({ ...userState, [field]: val });
    };

    return (
        <>
            <BasicUserInfo user={user} onChange={handleStateChange} />
            <Button onClick={onSubmit}>Submit</Button>
        </>
    );
};

const BasicUserInfo: FC<BasicUserInfoProps> = ({ user, onChange }) => (
    <>
        <h3>User info:</h3>
        <UserContactInfo user={user} onChange={onChange} />
    </>
);

const UserContactInfo: FC<UserContactInpoProps> = ({ user, onChange }) => (
    <div>
        <PhoneNumberInput value={user.number} onChange={(val: string) => onChange('number', val)} />
        <EmailInput value={user.email} onChange={(val: string) => onChange('email', val)} />
    </div>
)

In the first case, the Toggle component acts like a wrapper, reorganising component logic into two independent presentation components down one level. This is, for the most part, a cleaner solution than both keeping everything in one component and introducing some other structure to handle the common context.

In the second example, the user object is passed down 2 levels, with the middle layer being needlessly aware of user. The only reason for user to be a prop of BasicUserInfo is for it to be a prop for UserContactInfo. There’s now an opportunity to add more links to this chain of components, hide some logic somewhere within, and potentially break everything with each subsequent change. The difference is subtle but it’s there.

The takeaway is that if you’re drilling to organise your presentation components, you’re in the green. If the answer to the question “can you edit parts of the application without breaking others” becomes “no”, you’re in the red.

It’s important to remember that oftentimes instead of genuine need for additional complexity, what you’re looking at is code smell. Consider carefully if you are splitting your components up in a sensible way, if state is defined where it’s needed.

Redux.

This is a very common solution, but tricky to get right. Contrary to alarmingly popular belief, Redux is not a solution for managing component state. The point of the matter is: Redux is not about state management. Redux is about flux.

Flux

Redux is a fantastic tool to be used for business level entities, meaning the best use case for it is fetching data from the backend, optionally structuring it and deriving a more useable data structure, then selecting it in a way that is appropriate.

When you feel the urge to migrate some stateful logic to redux, ask yourself the following questions:

  • Is the data needed in more places than one?
  • Do you need to permute original state to derive some other state?
  • Do you in all honesty need the entire pipeline and accompanying baggage of the flux pattern to manage your persistent bool value?

It’s great at what it does well, but it’s hardly a catch all solution.

React context

A far more appropriate way to handle simple shared stateful logic is provided by the React library itself. Back in the troubled age of class components early React development, Redux store and dispatch were served through an explicitly defined provider and consumer. Basically, context solves the biggest gotcha of unidirectional data flow for React.

Context

Sadly, you encounter more issues with more complexity. What ends up happening in practice is you aim for separation of concerns, spawning multiple independent context managers all defined in the most practical place: the root.

Context hell

Once your app’s wrapper is wrapped in a wrapper, you need to consider other options.

Observers

There exists in this world an irrational fear of forceUpdate. It was provided early on with a friendly warning in the docs: you should not need this if you’re doing things the React way. I’m here to tell you that it’s ok to not do things the React way, granted that what you’re doing makes sense.

In some cases, the common stateful logic you need does not rely on storing variables as much as notifying other parts of the application of events. The observer pattern to the rescue!

Event manager

An observer-based event manager in its simplest form is trivial to implement.

export class PubSub {
  subscribers: { [key: string]: Subscriber } = {};

  public subscribe(subscriber: Subscriber, dedup: string = nanoid(), repeatHistory = false) {
    this.subscribers[dedup] = subscriber;
  }

  public async fireEvent(eventName: string, data?: object) {
    const promiseChain: any[] = [];

    Object.values(this.subscribers).forEach((subber: Subscriber) => promiseChain.push(subber(eventName, data)));

    await Promise.allSettled(promiseChain);
  }
}

export default new PubSub();

This is a great, minimalist solution for when you know what needs to happen and when, it does not adhere to the structure of your component tree and there is no conceivable need to wrap it all in flux.

The slightly unconventional part comes when you realise that notifying an observer in and of itself updates neither props nor state, and therefore, nothing is reconciled and no change is triggered. You may need to call forczeUpdate. Just, please, if you must use a custom hook for it, don’t import a library’s worth of bells and whistles to go with it, I’m sure your project is bloated enough as is.

October 13, 2021

Dmitriy Efimov

Frontend engineer