Implementing Realtime Search using React and Laravel
Modern data-driven applications usually contain terabytes of searchable data. Users expect search queries to produce concise results in milliseconds. <!--more--> Developers build such functionality using technologies such as AJAX, Single-Page Applications, and Caches.
In this tutorial, you will build a real-time movie search application using React and Laravel.
You will also learn about query optimization and how to reduce an app's query search time using Redis.
Prerequisites
To follow along, you should have some basic understanding of React and Laravel.
Frontend requirements: Node.js
Next.js powers the frontend of this application which in turn, runs on Node.js.
Ensure that you are running Node.js version 12 or higher. If you have a lower version, install the latest LTS version from the official website.
Backend requirements: PHP, Composer and MySQL
Laravel, a PHP framework, powers the backend of the application. It uses Composer for dependency management. To set up the backend, ensure that you have:
- PHP version 7.4 and greater. Install the latest version here.
- Composer version 2.1 and greater. Install the latest version here.
- MySQL version 5.7 and greater. Install the latest version here.
If you are on windows, consider installing XAMPP. It contains PHP and MYSQL in one package.
Redis
Redis is an in-memory key-value store used for quick data retrieval and cache management. Applications with time-sensitive query requirements use Redis.
This movie search application will use Redis as a cache to reduce query time for already performed operations.
Follow the instructions here to install Redis on your machine.
For windows users, use these instructions instead.
Frontend setup
The frontend code can be found on this Github repo. Follow the instructions below to clone the repo and install dependencies.
git clone https://github.com/vicradon/movie-search-frontend.git
cd movie-search-frontend
npm i
After installing dependencies, start the local server:
npm run dev
Backend setup
The backend code is available on this Github repo. To set it up, follow these steps:
- Clone the repo:
git clone https://github.com/vicradon/movie-search-backend.git
- Change directory into the repo:
cd movie-search-backend
- Install dependencies:
composer install
- Set up the application key:
php artisan key:generate
- Create a
.env
file:
cp .env.example .env
- Create the database in the MySQL shell:
CREATE DATABASE movies_search_app;
Ensure that the database configuration in the .env
file corresponds to your local database settings.
- Run the migrations:
php artisan migrate --seed
The migrations create a film
table that contains fictional movies. The original source of the data is the sakila MySQL sample database.
- Start the application:
php artisan serve
If the backend server runs without crashing, you should be able to make requests on the frontend.
Frontend architecture
The front-end is a basic page component with an input box. Whenever you type a letter in the search box, the app makes an API call to the search endpoint.
Frontend code walkthrough
The movie search page starts with an empty movies state
, a query state for the typed text, and an input box component.
function Index() {
const [query, setQuery] = useState("");
return (
<Input
size="lg"
placeholder="Search by title, e.g. Bird"
mb="1rem"
name="query"
value={query}
onChange={({ target }) => setQuery(target.value)}
type="search"
width={{ base: "90vw", md: "600px" }}
bg="white"
/>
);
}
To keep the codebase organized, a searchByTitle
function is defined in the api/movies.ts
file.
import http from ".";
export const searchByTitle = async ({ title, fetchCached }) => {
const searchParams = new URLSearchParams({
title,
fetchCached: String(Number(fetchCached)),
});
const { data } = await http.get(`/movies?${searchParams}`);
return data;
};
The function calls the /movies
endpoint with the given title
and an option to fetch cached items in the URL search parameters.
The method uses the global http
configuration defined in api/index.ts
. The http object is an axios instance
that contains the base URL
and the timeout request.
import axios from "axios";
const http = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 90000,
});
export default http;
A useEffect
hook is then added which fires a callback when the page mounts and when the query state changes.
import { searchByTitle } from "../api/movies";
function Index() {
const [query, setQuery] = useState("");
const [movies, setMovies] = useState([]);
...
useEffect(() => {
if (query) {
setLoading(true);
setMovies([]);
searchByTitle({ title: query, fetchCached })
.then(({ data }) => {
setLoading(false);
setMovies(data.movies);
setQueryTime(data.duration_in_milliseconds);
})
.catch((error) => {
handleError(error);
setLoading(false);
});
}
}, [query]);
return (
...
}
The server response contains a movies array
and the query duration
in milliseconds. Below is the base state of the server's response:
{
"movies": [],
"duration_in_milliseconds": 0
}
Backend architecture
The backend follows a simple flow of running search queries on the database. Whenever the frontend sends a request to the backend, it processes it and sends it to the database.
Backend code walkthrough
The two most important files in the backend are:
app/Http/Controllers/MoviesController.php
contains the query logic.routes/api.php
contains the API routes.
The API endpoint for fetching movies calls the index
method in the movies
controller:
Route::get('/movies', [MoviesController::class, 'index']);
The index
method in the MoviesController.php
validates the request data and resolves the request to either fetch data from the database or from the cache.
class MoviesController extends Controller
{
public function index(Request $request)
{
$validatedData = $request->validate([
'title' => ['required'],
'fetchCached' => ['boolean'],
]);
$fetchCached = $validatedData['fetchCached'];
$title = $validatedData['title'];
if ($fetchCached) {
return $this->fetchFromCache($title);
} else {
return $this->fetchFromDB($title);
}
}
...
}
When you make a request without specifying the fetchCached
query parameter or setting its value to "0", the index
method resolves to the fetchFromDB
method.
This method uses the eloquent Film
model to perform a query with the sql "LIKE" operator under the hood.
It also records the start and end time of this query and returns it as a response object.
public function fetchFromDB($title)
{
$start_time = now();
$movies = Film::where('title', 'like', "%{$title}%")->get();
$finish_time = now();
return response()->json([
'data' => [
'movies' => $movies,
'duration_in_milliseconds' => $finish_time->diffInMilliseconds($start_time)
],
]);
}
Testing the application
With both the frontend and backend servers running, navigate to http://localhost:3000 in a browser. You should be presented with the following results:.
Make a query with bird
as the input in the search box. It should return 9
results in an average time of 60
milliseconds.
If you analyze the current situation, you'll notice that each character entered by the user invokes the searchByTitle
function which makes the HTTP call.
If it takes an average of 60ms
to make one HTTP request. This means that we will take roughly 240ms
for four
HTTP requests. These requests correspond to the number of characters in the word bird
.
Improving query time with Redis
Redis helps to transform the backend architecture, as shown below:.
The fetchFromCache
method in MovieController.php
contains the code that interacts with Redis.
First, the start time
is saved to a variable, then the function attempts to retrieve movies that correspond to the search title from Redis.
If cached data exists, the function records the request's end time
and returns a serialized object of the cached movies alongside the duration in milliseconds.
If there's no cached data for the corresponding request, the fetchFromCache
function fetches movies directly from the database, records the finish time, and sets the search title and response as key-value pairs in Redis.
public function fetchFromCache($title)
{
$start_time = now();
$cached_movies = Redis::get($title);
if ($cached_movies) {
$finish_time = now();
return response()->json([
'data' => [
'movies' => json_decode($cached_movies),
'duration_in_milliseconds' => $finish_time->diffInMilliseconds($start_time),
]
]);
} else {
$movies = Film::where('title', 'like', "%{$title}%")->get();
$finish_time = now();
Redis::set($title, $movies);
return response()->json([
'data' => [
'movies' => $movies,
'duration_in_milliseconds' => $finish_time->diffInMilliseconds($start_time)
],
]);
}
}
Testing the application with Cache
To test the app, turn on the fetch cached filter
in the filters
dropdown.
Now, make a query with bird
as input. You should still get a query time close to 60 seconds because the backend still fetches data from the database while caching the result.
If you repeat the bird
query, you should see query time as low as 10 milliseconds. This is an 80% average decrease in the query time.
Conclusion
Modern systems with search functionality have to produce results in milliseconds. You can build such a system using a Single Page Application, AJAX requests, and a cache such as Redis.
In this article, you learned about the components and architecture of a real-time search system.
You also learned how to optimize queries using Redis. You can, therefore, use this knowledge to craft more quality web applications.
Further learning
Peer Review Contributions by: Wanja Mike