Learn render props by building a Lunr powered reusable client-side search component.
There are several components that give easy access to search powered by server side technology (like react-instantsearch for algolia, or reactivesearch for elasticsearch). These are awesome if you have a big set of data to search through, but sometimes you have a smaller set of data and you need to do that searching client side. Maybe you just don’t want to set up a server-side search solution or you need to perform the search operations offline.
Lunr is a great full-text search library that can run client-side. Just give it some documents, and search through it. For smaller sets of data, this is probably all you need. So let's create a component that gives us client-side search capabilities!
Note: This tutorial assumes that you are familiar with React (via Create React App) and ES6 syntax.
What we’ll cover:
- Rendering astronauts in a simple list
- Adding Lunr to make the list searchable
- Abstracting document structure allowing documents to be passed in as props
- Reusability through a children render prop function
- Catching errors with an error boundary component
Getting Started with a Simple List
We’ll use create-react-app to get our react environment setup, so run
npm init react-app react-search-lunr
cd react-search-lunr
npm start
To start, we’ll make a simple component that lists some astronauts. Eventually, this will have some search capability, but we need to start somewhere. So, for now, just iterate over a list of astronauts and render them.
Grab a copy of the moonwalkers file:
cd src/
curl -O https://gist.githubusercontent.com/jonotron/d97dd22b2a64a1a672d938c870b42561/raw/2ce99e467d0455cd07ee3ab1e33586cf36e47288/moonwalkers.js
Now add a new component, src/react-search-lunr.js
:
And change the contents of App.js
that create-react-app made for us to this:
Our component just grabs moonwalkers
, and iterates over them to display their name
and body
. Don't forget, you need to use key={i}
when we map
in JSX.
Go to your browser and you should see something like this:
Add Search with Lunr and an Input
So that’s great, but not super functional. Now we’ll add some simple Lunr search capabilities by adding an input
element and using its value to power the results. You'll need to install the lunr
package
npm install lunr
At the top of your component, import the lunr
package
import lunr from 'lunr'
Lunr requires that you create an index first. You need to specify the unique identifier (called ref
in Lunr lingo), all the fields you want to search on, and all the documents to search. The component constructor seems like a good place to do this.
In your component add a constructor and build the index.
This adds the name
and body
fields to our index and sets the id
field as the ref
field (or the unique ID for the document) and iterates over each moonwalker
and adds it to the index. We then set up some state in our component that we are going to use just below. filter
will contain the text that the user types in the input box, index
is our Lunr index, and results
will contain the results of a search in the index
with the filter
text.
Note: You don’t have to add all the fields in the documents to the index, only those that you want to search. If your documents have some extra metadata that doesn’t make sense to search, don’t add them with the field()
function.
Now let’s add an input
element so we can type a filter and search the index. Add a handler function and change your render()
function to your component like this:
Now our render()
function uses an input
element so the user can type search filters. The input from the user is stored as state (this.state.filter
).
What’s going on in the handleChange
function?
Our input
is a controlled component, so handleChange
is updating our component's state with the filter text from the input and using that text to search the Lunr index.
When you search for something in Lunr, it does not return the matching document, just the unique ID (and some meta data) about the matching document. Remember, we had to add the id
field as the ref
in the Lunr index? When you .search()
on a Lunr index, it returns an array of objects with ref
as a field. In our case, this refers to the id
field of the original moonwalker document.
The .map()
call is taking each result from the .search()
, destructuring out the ref
field, and then adding an item
field to the results. This item
field is a reference to the original document (making it really easy to show the data from the original document in our render()
). moonwalkers.find(m ⇒ m.id === ref)
finds the original moonwalker.
This allows us to use {result.item.name}
in the render()
function.
Try searching for an astronaut. Type ‘alan’ into the input box and you will see results for ‘alan bean’, ‘edgar mitchel’ (because Alan Shepard is mentioned in his bio), and ‘alan shepard’
Passing Search Data Through Props
Great, we have a component that we can use to search for moonwalkers. But it’s not exactly reusable. It only searches moonwalkers that the component provides for us. We’d like to provide the moonwalkers to the component, declaratively. Let's pass moonwalkers in as a prop.
Update your App.js
to look like this:
And remove the moonwalkers
import from the component and change the constructor()
and handleChange
functions to use moonwalkers as a prop, like this:
That’s a bit more reusable, now our data can be passed in declaratively at runtime as a prop.
Allow Any Document Structure
Our component still has some assumptions about the data. It loads specific fields into the index. Let’s abstract that away and allow the fields to also be passed in via props.
Change the component’s constructor to set the ref via an id
prop and iteratively add fields from a fields
prop. We also add the documents to state as we want to ensure that index
and documents
are based on the same set of data:
If you’re adding propTypes
, it should look like this now:
ReactSearchLunr.propTypes = {
documents: PropTypes.arrayOf(PropTypes.object),
id: PropTypes.string.isRequired,
fields: PropTypes.arrayOf(PropTypes.string).isRequired
}
Update the handleChange
function to use the documents
on state:
Now you can update App.js
to pass in these props:
Note: We can’t use ref
as our prop name because that's reserved by React for dom references, so we call it id
.
Great! Now our Lunr index can be setup declaratively with the fields
and ref
(as id
) passed in as props. However, our component is still tightly coupled to our moonwalkers document structure. The render function explicitly uses the name
and body
fields of the results. As a user of this component, I don't want to have to always render a h1
and a p
element for each moonwalker. I want to do my own thing.
Composition Through Children as a Render Prop
What I want is to declaratively display results however I please. This is where render props are helpful. They let you pass in a function that returns JSX that your component will render for you.
Hopefully, this makes more sense as an example. Change the render()
function of your component to look like this:
And update your propTypes
:
ReactSearchLunr.propTypes = {
documents: PropTypes.arrayOf(PropTypes.object),
id: PropTypes.string.isRequired,
fields: PropTypes.arrayOf(PropTypes.string).isRequired,
children: PropTypes.func
}
So our render function used to just map
over the results and return a h1
and a p
tag. It's still mapping over the results
but instead of returning JSX, it's calling the children
prop as if it were a function, and passing to it each result
.
children
is just a prop, we can ask that it be anything we want with propTypes
. Here, we've asked that it be a function, because we invoke it for every result in our render()
function. As long as the function that gets passed into the children
prop returns JSX, things will work just fine.
So now our App.js
render()
function can be updated like so:
We’ve declared the children
of <ReactSearchLunr>
to be a function (not some elements or other components like you might be used to). This function will be called by <ReactSearchLunr>
with every result from our search. So that <p key={result.ref}>
tag (and its contents) will be repeated wherever children
is called in the <ReactSearchLunr>
component.
This is great because as a user of the ReactSearchLunr
component, I can get access to the results without having to monkey with the internals of the component. I just know that results will be provided to my function that I pass in as children
to the component.
Now I’m free to display the results however I want or even use those results to look up data from other sources.
In this case we’ve changed the display to render the name
inline with the body
.
This is much more reusable, but what about the search input box? Maybe I don’t always want the input box above the results like this, or maybe I want to provide the search text some other way or from elsewhere in my application. Let’s provide the search string as a prop too.
Providing everything as a prop
Lets get rid of that input
inside of our component and just pass filter
in as a prop. Remove the handleChange
function and add a getResults
function and change the render()
function like so:
You’ll want to update your propTypes
to:
ReactSearchLunr.propTypes = {
documents: PropTypes.arrayOf(PropTypes.object),
id: PropTypes.string.isRequired,
fields: PropTypes.arrayOf(PropTypes.string).isRequired,
filter: PropTypes.string,
children: PropTypes.func
}
Now we can pass filter
into our component as a prop. Our render()
function will recompute the results anytime the filter
changes (remember, React components are re-rendered whenever a prop changes) using the getResults()
function.
Let’s update the App.js
file to show the input box to the user:
Better Composition with Children Render Function
Ok, now lets modify it slightly so we can have even more control over the results. Let’s send the entire result set to the children
render prop function instead of calling it with every result. Change the ReactSearchLunr
component's render()
function to this:
render() {
return this.props.children(this.state.results)
}
Now our component simply calls the children
render prop with the results, and we can compositionally choose how to render the results.
Update App.js
to pass in a render function that takes all the results:
Now whenever the results are updated, ReactSearchLunr
will call our renderResults
function with the results
. From there we can do whatever we want (including filtering or sorting the results if we wanted).
Tip: I find too many nested functions as children a bit hard to read, so I’ve opted to just pass a function into the children of my ReactSearchLunr
component. You could also pass it in directly as the children
prop (<ReactSearchLunr children={renderResults} />
)
Handling Errors
If you try using some of the more advanced features of Lunr (e.g. searching for name:alan
) you will get a QueryParseError
.
Lunr is throwing an error when it tries to search for the filter. Since we are not catching this error, react is unmounting the entire application. We want to catch this error and allow the user to continue typing. For this we need an error boundary which we can use to wrap our ReactSearchLunr
instance.
In our App.js
create a new class component called ErrorBoundary
and wrap it around the ReactSearchLunr
component.
This ErrorBoundary
component will now catch any error thrown by its children. There is a slight issue with the error boundary in that it doesn't reset it's state to try rendering ReactSearchLunr
when the filter
changes. We could pass filter
into the ErrorBoundary
and have it derive state from the filter
prop, but that's more complicated than we need. Here's a little React trick: you can change the key
prop of any component to reset it. So we just set key={this.state.filter}
on the ErrorBoundary
which will reset the component whenever the filter
changes. Easy peasy.
Now if you try typing name:
React will still display the error in development mode but if you close the error overlay you can see that the underlying app still works.
Conclusion
Awesome! Now we have a nice component that can be re-used pretty much however you want.
Sure there are some performance improvements we could make, but we’ve covered a lot of things. Hopefully, this gives you a bit more understanding about render props and how they can help you build very reusable components. We’ve also dabbled a bit in error boundaries and how to prevent component errors from wrecking your day.
If you are just interested in using the ReactSearchLunr
component, we’ve open sourced it here: https://github.com/TwoStoryRobot/react-search-lunr
We built this little component as a result of some work with a client. If you’re looking for a capable team of javascript folks, we’d love to chat: https://www.twostoryrobot.com
Please share this with your fellow React friends. Render props are not as scary as they might seem at first. Any 👏 are appreciated.