DEV Community

Alfonsina Lizardo
Alfonsina Lizardo

Posted on • Edited on

Building a Real-Time Search Filter in React: A Step-by-Step Guide

Table of Contents

 1. Introduction
 2. Requirements
 3. Step 1: Setting Up the Project
 4. Step 2: Creating the Input
 5. Step 3: Rendering the list of items
 6. Step 4: Building the filtering functionality
 7. Step 5: Getting the items from an API and filtering them
 8. Step 6: Refactoring a little (Bonus)
       8.1. ItemList Component
       8.2. Input Component
       8.3. API call
 9. Conclusion

Introduction

When I began working with React, one of the common challenges I encountered was implementing a real-time search filter functionality. This feature updates the displayed items as the user types and shows all the items again if the search filter is empty. So in this tutorial, I will guide you through the steps to create this feature in React. We'll start with a list of hard-coded items and then proceed to a list of items obtained from an API.

By the end of this tutorial, you'll have a solid understanding of how to build this valuable feature. We’ll begin by implementing the complete functionality in App.jsx and then refactor it into reusable components.

Let's get started!

Requirements

  • Npm and Node.js installed
  • Basic React Knowledge

Step 1: Setting Up the Project

To set up the project, we will be using Vite. So open your terminal and execute the following commands:

npm create vite search-filter --template react
Enter fullscreen mode Exit fullscreen mode

Feel free to replace “search-filter” with any name you prefer for your project. This should be enough to create the project. However, if you come across any options in your command-line interface, make sure to select “React” when prompted to choose a framework, and “Javascript” when asked to select a variant.

Now, navigate into the project directory and install the required packages by executing the following commands:

cd search-filter
npm install
Enter fullscreen mode Exit fullscreen mode

After that, you can run the command npm run dev. You should be able to open the project at http://localhost:5173/ and see something like this:

Step1

Step 2: Creating the Input

Now, open the project in your preferred IDE (I personally use VSCode), and navigate to the src/App.jsx file. Delete its contents to start with a clean slate. If you prefer to work without the default styles, you can remove the import './index.css' line from the src/main.jsx file. You may also choose to leave it intact if you want to keep the default styles. In my case, I will remove it.

We’ll begin by building the entire functionality within App,jsx, and we can refactor it later into reusable components.

First let’s create our search input with its proper state to control its value:

// src/App.jsx
import { useState } from 'react'

function App() {
  const [searchItem, setSearchItem] = useState('')

  const handleInputChange = (e) => { 
    const searchTerm = e.target.value;
    setSearchItem(searchTerm)
  }

  return (
    <div>      
      <input
        type="text"
        value={searchItem}
        onChange={handleInputChange}
        placeholder='Type to search'
      />
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

With this, we have implemented a functional input. We’re setting its value to the searchItem state, which updates whenever the user types in the input thanks to the onChange event handler. We could simply use the setSearchItem function directly in the input, but we’re doing it like this because we’re adding some more functionality to the handler function soon.

You should have something like this:

Step 2

Step 3: Rendering the list of items

Next, let’s add a list of items and render it below the input.

// src/App.jsx
import { useState } from 'react'

const users = [
  { firstName: "John", id: 1 },
  { firstName: "Emily", id: 2 },
  { firstName: "Michael", id: 3 },
  { firstName: "Sarah", id: 4 },
  { firstName: "David", id: 5 },
  { firstName: "Jessica", id: 6 },
  { firstName: "Daniel", id: 7 },
  { firstName: "Olivia", id: 8 },
  { firstName: "Matthew", id: 9 },
  { firstName: "Sophia", id: 10 }
]

function App() {
  const [searchItem, setSearchItem] = useState('')

  const handleInputChange = (e) => { 
    const searchTerm = e.target.value;
    setSearchItem(searchTerm)
  }

  return (
    <>
      <input
        type="text"
        value={searchItem}
        onChange={handleInputChange}
        placeholder='Type to search'
      />
      <ul>
        {users.map(user => <li key={user.id}>{user.firstName}</li>)}
      </ul>
    </>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Perfect, now we have a list of items which we’re storing in the users variable and then we’re using the map method to loop through the list and render each item using a li tag.

You should be seeing something like this:

Step 3

Step 4: Building the filtering functionality

Great! We have our input and we have our items, now let’s get to the fun part 👀 We need to change the list of items being rendered depending on what the user types in the input. For that, we need to do 3 things:

  1. Add a state to save the filtered items, and set it to the users variable,
  2. On the event handler, we need to filter the items depending on what the user is writing and set the result to the filtered items state
  3. Render the filtered items instead of the users variable

So let’s get to it:

// src/App.jsx
//... users variable and imports
function App() {
  const [searchItem, setSearchItem] = useState('')
  const [filteredUsers, setFilteredUsers] = useState(users)

  const handleInputChange = (e) => { 
    const searchTerm = e.target.value;
    setSearchItem(searchTerm)

    const filteredItems = users.filter((user) =>
    user.firstName.toLowerCase().includes(searchTerm.toLowerCase())
    );

    setFilteredUsers(filteredItems);
  }

  return (
    <>
      <input
        type="text"
        value={searchItem}
        onChange={handleInputChange}
        placeholder='Type to search'
      />
      <ul>
        {filteredUsers.map(user => <li key={user.id}>{user.firstName}</li>)}
      </ul>
    </>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

A common mistake I used to make here is that when filtering the items in the handler function, I used the filtered items state (filteredUsers) instead of the variable that contained all the items (users), which led to the unwanted behavior of the filter working when you first start typing on the input but then if you erase some characters the items don’t get filtered again, and if you erase all the characters you don’t see the whole list of items again.

This happens because when the component first loads, filteredUsers initial state contains all the users, but when you start typing, it gets updated to a new array of users that matches the search input value, and then it can never access the complete list of users again.

So it’s really important that when you do the filtering, you always do it using the variable that contains all the items.

This is working great right now, you should have something like this:

Step4

But in real life, it’s more common that you need to get the list of items from an API an then filter those items, so let’s see how we can do that.

Step 5: Getting the items from an API and filtering them

Let’s use the DummyJSON API to get the users. We need to fetch the users when the component loads (we can use an useEffect for that) save them to a state and then render them on the list. Well, we already have the filteredUsers state, and that’s the state that we’re using to render the users, so maybe we can store the users gotten from the API there and filter those same users, right?

Well… no, this is another common mistake that I used to make 😕. If we do that, it would be the same mistake I mentioned above, when the component first loads, the filteredUsers state is empty, then the useEffect gets executed and the users are fetched from the API, set to the filteredUsers state and then rendered, now you have the complete list of items in filteredUsers so you use that to do the filtering, but when you start typing, that state gets updated and it can never access to the complete list of users again.

So what can we do? well, the solution is to create another state, let’s call it apiUsers, that will hold the complete list of users when the component first loads, it will act kinda like the users variable we initially had. Then we use apiUsers, instead of filteredUsers to filter the items when the user types on the input. We no longer need the previous users variable so we get rid of it and initialize the filteredUsers state to an empty array.

// src/App.jsx
import { useState, useEffect } from 'react'

// We no longer need the users variable so you can remove it from here

function App() {
  // add this state
  const [apiUsers, setApiUsers] = useState([])
  const [searchItem, setSearchItem] = useState('')
  // set the initial state of filteredUsers to an empty array
  const [filteredUsers, setFilteredUsers] = useState([])


  // fetch the users
  useEffect(() => {
    fetch('https://dummyjson.com/users')
      .then(response => response.json())
      // save the complete list of users to the new state
      .then(data => setApiUsers(data.users))
      // if there's an error we log it to the console
      .catch(err => console.log(err))
  }, [])

  const handleInputChange = (e) => { 
    const searchTerm = e.target.value;
    setSearchItem(searchTerm)

    // filter the items using the apiUsers state
    const filteredItems = apiUsers.filter((user) =>
      user.firstName.toLowerCase().includes(searchTerm.toLowerCase())
    );

    setFilteredUsers(filteredItems);
  }

  return (
    // ... component rendering
  )
}
Enter fullscreen mode Exit fullscreen mode

This is all well and good but….. why can’t we see any items now when the component first loads? 😱 the answer is that we’re mapping over the filteredUsers state to render the users, and it starts empty now, so no user is being shown until you start typing on the input. How can we render the full list of users when the component first loads and render the filtered users when we type on the input?

There’s a simple solution for that. Previously we initialized the filteredUsers state with the complete list of users, we can’t do that now because the complete list of users is being stored in apiUsers and that state starts as an empty array as well, but it gets updated when we fetch the users right? so all we need to do is to store the users in the apiUsers state AND in the filteredUsers state as well when fetching them from the API.

// src/App.jsx
import { useState, useEffect } from 'react'

function App() {
  // ...states

  useEffect(() => {
    fetch('https://dummyjson.com/users')
      .then(response => response.json())
      .then(data => {
        setApiUsers(data.users)
        // update the filteredUsers state
        setFilteredUsers(data.users)
      })
      .catch(err => console.log(err))
  }, [])

  // ...handler and component rendering

}
Enter fullscreen mode Exit fullscreen mode

Awesome! that fixed the issue 🙌🏼 Now we have a fully functional item filtering functionality with data coming from an API 🎉

Step 5

You can see that I also added a “No users found” message if no user matches with what we write in the input, you can do it by just editing the part where the users are being rendered, and make it like this:

// src/App.jsx
import { useState, useEffect } from 'react'

function App() {
  // ...state, data fetching, handler

  return (
    <>
      <input
        type="text"
        value={searchItem}
        onChange={handleInputChange}
        placeholder='Type to search'
      />
      {filteredUsers.length === 0
        ? <p>No users found</p>
        : <ul>
          {filteredUsers.map(user => <li key={user.id}>{user.firstName}</li>)}
        </ul>
      }      
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

However you might notice that the message “No items found” flashes shortly when first rendering the page, we can fix that by adding a loading state, so that if the users are being fetched you can show a “Loading…” message or a spinner, we can also add an error state to show a proper error message if the fetching doesn’t work.

// src/App.jsx
function App() {
  const [apiUsers, setApiUsers] = useState([])
  // initialize the loading state as true
  const [loading, setLoading] = useState(true)
  // initialize the error state as null
  const [error, setError] = useState(null)
  const [searchItem, setSearchItem] = useState('')
  const [filteredUsers, setFilteredUsers] = useState([])

  useEffect(() => {
    fetch('https://dummyjson.com/users')
      .then(response => response.json())
      .then(data => {
        setApiUsers(data.users)
        setFilteredUsers(data.users)
      })
      .catch(err => {
        console.log(err)
        // update the error state
        setError(err)
      })
      .finally(() => {
        // wether we sucessfully get the users or not, 
        // we update the loading state
        setLoading(false)
      })
  }, [])

  //... on change handler

  return (
    <>
      <input
        type="text"
        value={searchItem}
        onChange={handleInputChange}
        placeholder='Type to search'
      />
      {/* if the data is loading, show a proper message */}
      {loading && <p>Loading...</p>}
      {/* if there's an error, show a proper message */}
      {error && <p>There was an error loading the users</p>}
      {/* if it finished loading, render the items */}
      {!loading && !error && filteredUsers.length === 0
        ? <p>No users found</p>
        : <ul>
          {filteredUsers.map(user => <li key={user.id}>{user.firstName}</li>)}
        </ul>
      }
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Refactoring a little (Bonus)

All is working well now, but what if we want to reuse the input and the list somewhere else? we could refactor this a little to make the components reusable, that way we can avoid repeating code. Let’s see step by step how we can do that.

ItemList Component

First create a new components folder under src. Let’s do the item list first, so create a new file called ItemList.jsx under src/components and copy the section that was rendering the items (just the mapping, without the loading and error state checking) to the newly created file. The items to be rendered as a list will now be gotten through props, so let’s change the filteredUsers variable to items to make it more general. The new component should look like this:

// src/components/ItemList.jsx
// get the items in the props
const ItemsList = ({items}) => {
  return (
    <>
      {/* replace filteredUsers with items*/}
      {items.length === 0
        ? <p>No users found</p>
        : <ul>
          {items.map(item => <li key={item.id}>{item.firstName}</li>)}
        </ul>
      }
    </>
  )
}

export default ItemsList
Enter fullscreen mode Exit fullscreen mode

Excellent, now let’s use our new component in the App.jsx file, replace the list of items being rendered with the new component and pass the filteredUsers as the value for the items prop.

// src/App.jsx
// ... other imports
import ItemList from './components/ItemsList'

function App() {

  //... component logic

  return (
    <>
      <input
        type="text"
        value={searchItem}
        onChange={handleInputChange}
        placeholder='Type to search'
      />
      {loading && <p>Loading...</p>}
      {error && <p>There was an error loading the users</p>}
      {!loading && !error && <ItemList items={filteredUsers} />}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Input Component

Let’s do the Input now, create a new file called Input.jsx under src/components, and copy the input tag from src/App.jsx to the new component, which should now look like this:

// src/components/Input.jsx
const Input = () => {
  return (
    <input
      type="text"
      value={searchItem}
      onChange={handleInputChange}
      placeholder='Type to search'
    />
  )
}

export default Input
Enter fullscreen mode Exit fullscreen mode

Now, on this new component, we don’t have access to the searchItem state and the handleInputChange function so that will throw an error. We could pass those 2 values as props, but let’s do something more interesting. Let’s make it so that the input element can manage its own value inside this component, so we create a new state which will handle that, and a new handler function to update the state.

You might be thinking how can we then filter the values on our App component if it will no longer have access to the input value? Well the answer is: ¡With a callback! We can pass a callback function as a prop that will run inside the handler function and will take the input value as its argument. So change the Input.jsx component to this:

// src/components/Input.jsx
import { useState } from "react"

const Input = ({ onChangeCallback }) => {
  // state to handle the input value
  const [value, setValue] = useState('')

  // new handler function that will update the state 
  // when the input changes
  const handleChange = (e) => {
    const inputValue = e.target.value;
    setValue(inputValue)
    // if the component receives a callback, call it,
    // and pass the input value as an argument
    onChangeCallback && onChangeCallback(inputValue)
  }

  return (
    <input
      type="text"
      value={value}
      onChange={handleChange}
      placeholder='Type to search'
    />
  )
}

export default Input
Enter fullscreen mode Exit fullscreen mode

And change the App.jsx file to import our new Input component and remove all the unnecessary code, remember to pass the previous handler function in App.jsx as a prop to the Input component:

// src/App.jsx
import { useState, useEffect } from 'react'
// import the Input component
import Input from './components/Input'

function App() {
  const [apiUsers, setApiUsers] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  // we no longer need the searchItem state so you can remove it
  const [filteredUsers, setFilteredUsers] = useState([])

  // ... useEffect code without changes here

  // this is the previous handleInputChange function, I changed
  // its name to better represent its new functionality of only 
  // filtering the items
  const filterItems = (searchTerm) => { 
    // we previously set the input state here, 
    // you can remove that now
    const filteredItems = apiUsers.filter((user) =>
      user.firstName.toLowerCase().includes(searchTerm.toLowerCase())
    );

    setFilteredUsers(filteredItems);
  }

  return (
    <>
      {/* Use the new Input component instead of the input tag */}
      <Input onChangeCallback={filterItems} />
      {loading && <p>Loading...</p>}
      {error && <p>There was an error loading the users</p>}
      {!loading && !error && <ItemList items={filteredUsers} />}
    </>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Perfect! The filtering should still be working as usual.

API call

We can also refactor our API call into a custom hook. Create a new hooks folder under the src folder and then create a new file there called useGetUsers.jsx. Let’s now move our apiUsers, loading, and error states, and the useEffect where we’re making the API call to our new hook. We then return the users, loading, and error states from our hook. We could also rename the apiUsers state to just users, since there are no other related variables in this new file.

// src/hooks/useGetUsers.jsx
import { useState, useEffect } from 'react'

export const useGetUsers = () => {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    fetch('https://dummyjson.com/users')
      .then(response => response.json())
      .then(data => {
        setUsers(data.users)
      })
      .catch(err => {
        console.log(err)
        setError(err)
      })
      .finally(() => {
        setLoading(false)
      })
  }, [])

  return { users, loading, error }
}
Enter fullscreen mode Exit fullscreen mode

You can see that we also removed the setFiltered state function from the useEffect, so how can we set the filtered users now? We just need to make the following modification to our App.jsx file.

// src/App.jsx
import { useState, useEffect } from 'react'
import Input from './components/Input'
import ItemList from './components/ItemsList'
// import our new hook
import { useGetUsers } from './hooks/useGetUsers'

function App() {
  // use our custom hook to get our users and 
  // the error and loading variables
  const {users, loading, error} = useGetUsers()
  const [filteredUsers, setFilteredUsers] = useState([])

  useEffect(() => {
    // check if the users are not empty, if so then the 
    // API call was successful and we can update our 
    // filteredUsers state
    if (Object.keys(users).length > 0) {
      setFilteredUsers(users)
    }
  }, [users]) // this effect should run when the users state gets updated

  const filterItems = (searchTerm) => { 
    // we now use 'users' instead of 'apiUsers' to do the filtering
    const filteredItems = users.filter((user) =>
      user.firstName.toLowerCase().includes(searchTerm.toLowerCase())
    );

    setFilteredUsers(filteredItems);
  }

  // ... rest of the component stays the same
}

export default App
Enter fullscreen mode Exit fullscreen mode

Conclusion

Woo! that was a lot to take in, right? 😅 but that’s it! You now know how to make a real time search filter in React with items coming from an API! 🤘🏼 And, if you went through the bonus step, you also learned some techniques on how to break your application into reusable components and custom hooks. Although, being honest, that refactoring is probably a bit of an overkill for such a small application, I did it just for the purposes of this article 😅

I hope you found this guide useful! And if there was something you didn’t understand or if you noticed something that can be improved, please feel free to mention it in the comments. Happy coding! 🤘🏼

Top comments (14)

Collapse
 
1qdan profile image
Daniel Trifan

One small observation on API call refactoring:

Here:
const filteredItems = apiUsers.filter((user) =>
user.firstName.toLowerCase().includes(searchTerm.toLowerCase())
);

you have to change apiUsers to users

Collapse
 
alais29dev profile image
Alfonsina Lizardo

You're right! thanks for the observation, I just made the change :)

Collapse
 
an_uncanny_mess profile image
parthcs-secondary

Great Post. Thank you for making this super simple

Collapse
 
alais29dev profile image
Alfonsina Lizardo

Thank you! I'm glad it was helpful :)

Collapse
 
riyazbaig profile image
Riyaz Baig

Good👍

Collapse
 
alais29dev profile image
Alfonsina Lizardo

Thanks!

Collapse
 
riyazbaig profile image
Riyaz Baig

hey hi i need your help can you do this in RAWGraphs api call

Thread Thread
 
alais29dev profile image
Alfonsina Lizardo

I haven't worked with that API before, but you should be able to apply the same filtering technique with any API call that fetiches a list of data, just change the API url with the one you need.

Collapse
 
folken718 profile image
OldMan Montoya

To make it more robust i would add a debouce function to avoid super fast and repetitive api calls

Collapse
 
alais29dev profile image
Alfonsina Lizardo • Edited

Thanks for the suggestion! I actually thought about it, but I didn't see it necessary in this particular case since I'm getting the complete list of users with the API call when the component first renders, so there's only ever one API call.

I wanted to also add the case of making the API call when typing on the input and adding the debounce function there, but that would have made the article even longer, so I decided to leave it at that, maybe I'll do an article about using the debounce function in the future :)

Collapse
 
folken718 profile image
OldMan Montoya

Absolutely, in your example is no needed, by the way great example

Thread Thread
 
alais29dev profile image
Alfonsina Lizardo

Thank you!

Collapse
 
najib2050 profile image
najib2050

Thanks for your dedication to help other in understanding such spectacular concepts.

Collapse
 
hasibrashid profile image
Hasib Al Rashid

Thanks for saving my time! I was stuck with this issue for like 4 hours. Thank you very much! 💖💖