If you're a React developer, you've probably heard of context. If you haven't, let me fill you in. Before I can explain, we'll need some (no pun intended) context. React allows you to share data between components via the use of props. This is great but we quickly run into problems when components that are deeply nested in our component tree require data that is also needed higher up in the tree. The most straight forward solution is to drill props or manually pass the data down the tree until it gets to where it is needed - yeaaaah not fun.
According to React's documentation, Context provides a way to pass data through the component tree without having to pass props down manually at every level ie. It allows us to skip drilling props like a crazy person. So how exactly does it work?
Let's have a look.
(This is the most contrived of examples)
1. Create Context
import { createContext } from "react";
export const MessageContext = createContext("");
2. Wrap Section of Component Tree in Context.Provider
// index.js
import React from "react";
import { MessageContext } from "./context";
import ChildA from "./components/ChildA";
import ChildB from "./components/ChildB";
export default function App() {
return (
<MessageContext.Provider value="Message from context">
<div style={{ fontFamily: "sans-serif", textAlign: "center" }}>
<ChildA />
<ChildB />
</div>
</MessageContext.Provider>
);
}
By wrapping this section of our component tree in the MessageContext.Provider
tag, we're now able to access the value of the provider from any descendant components.
3. useContext
import React, { useContext } from "react";
import { MessageContext } from "../context";
function ChildA(props) {
const message = useContext(MessageContext);
return (
<div>
<h2>ChildA</h2>
<p>{message}</p>
</div>
);
}
export default ChildA;
Now you have an idea of how Context works, how about we create our own version.
Creating Our Own Context
First, let's create a component that will function as our Provider. Here are a few requirements I came up with for our Provider component:
- Accepts any data passed via props
- Can be overridden by descendant components
- Data will be passed to all descendant components
I'll post the completed code and then provide a walk through of exactly what's happening.
ancestor/index.js
function Ancestor(){
function passProps(child) {
if (Object.hasOwnProperty.call(child.props, 'children')) {
const newChildren = Children.map(child.props.children, (_child) => {
if (isValidElement(_child)) {
return passProps(_child)
}
return _child
})
return cloneElement(child, {
...props,
...child.props,
children: newChildren,
})
}
return cloneElement(child, { ...props, ...child.props })
}
const descendants = Children.map(children, (child) => passProps(child))
return <>{descendants}</>
}
To iterate through each child present in our children prop, we make use of React's Children
property that exposes a map function similar to Array.map
:
const descendants = Children.map(children, (child) => passProps(child))
On each iteration, we pass an element to the passProps
function. passProps
is a recursive function that will iterate through every child and pass the props provided from our provider component.
passProps
will first check if the child passed has a children
property:
if (Object.hasOwnProperty.call(child.props, 'children'))
If it does, it will iterate through each element checking if it's a valid React component. If it is valid, we return passProps
with that element as an argument. Otherwise, we just return the element unchanged.
const newChildren = Children.map(child.props.children, (_child) => {
if (isValidElement(_child)) {
return passProps(_child)
}
return _child
})
This creates new children. Our next step is to create a clone of the child passed to passProps
and overwrite the children prop with our new children. React exposes a function called cloneElement. cloneElement functions similarly to Object.assign
allowing us to create a clone of the element and assign new values and props.
This is also where we want to enforce requirement #2. By destructuring the child.props
after the props
object, we ensure that any child props override props passed from our provider component.
return cloneElement(child, {
...props,
...child.props,
children: newChildren,
})
In the event that the child does not have children, we simply return a copy with our child props overriding the props from our provider component - similar to what we have above.
return cloneElement(child, { ...props, ...child.props })
Now to get this all working, we simply need to return our new and improved children.
return <>{descendants}</>
Let's try replacing the context provider from our example with our custom provider component but this time instead of a value, we'll pass a message
prop.
App.js
import React from "react";
import Ancestor from "./ancestor"
export default function App() {
return (
<Ancestor message="Message from the ancestor">
<div style={{ fontFamily: "sans-serif", textAlign: "center" }}>
<ChildA />
<ChildB />
</div>
</Ancestor>
);
}
How do we access the data passed down from Ancestor? Easy. We access it like any prop passed to a component.
ChildA.js
import React from "react";
function ChildA({message}) {
return (
<div>
<h2>ChildA</h2>
<p>{message}</p>
</div>
);
}
export default ChildA;
Boom! We just made our own version of context. Albeit, a contrived version that has performance issues 😂.
You might be wondering why you'd ever need this. You probably don't. If you ever need to avoid drilling props, just useContext. If you prefer to live life on the edge, I made this code into a package. Give it a go npm i react-ancestor
.
Thank you for reading! All questions and comments are appreciated 😄.
Follow me on Twitter @reactdon
Top comments (0)