In the last tutorial, we defined the basic structure for a scaleable react application. To demonstrate how it all comes together, we are going to build a Reddit client where a user can search about multiple topics and get results in form of a list.
In case if you did not already, please refer to Part I to understand the structure in depth.
Github: https://github.com/anishkargaonkar/react-reddit-client
Hosted On: https://reddit-client-88d34d.netlify.app/
The Reddit Client
Let's start by creating a container called Search at /src/cotainers/Search/Search.tsx
// /src/cotainers/Search/Search.tsx
import React, { FC } from "react";
type Props = {};
const Search: FC<Props> = (props: Props) => {
return (
<div>Search Container</div>
)
};
export { Search };
and add it to the Navigator component at /src/navigator/Navigator.tsx
// /src/navigator/Navigator.tsx
import React, { FC } from "react";
import { Switch, Route, BrowserRouter as Router } from "react-router-dom";
import { Search } from "../containers/Search/Search";
type Props = {};
const Navigator: FC<Props> = () => {
return (
<Router>
<Switch>
<Route path="/" component={Search} />
</Switch>
</Router>
);
};
export { Navigator };
After doing the above changes, the folder structure should look something like this
Adding search state
We'll be using Reddit's search API to query and fetch results. The format is given below
https://www.reddit.com/r/all/search.json?q=<query>&limit=<limit>
You can find more details on Reddit's official documentation
Let's define our API endpoints in .env
// /.env
REACT_APP_PRODUCTION_API_ENDPOINT = "https://www.reddit.com"
REACT_APP_DEVELOPMENT_API_ENDPOINT = "https://www.reddit.com"
In our case, both endpoints are going to be the same as we do not have separate environments for our app's back-end.
Before defining our redux state first we need to know how would our data looks, so let's first define the model by creating a file types.ts
in our Search container.
Generally, these models are decided early on before starting the project which off-course evolves over a period of time. Sometimes it might happen that we don't have a model beforehand and in that case, the developer is free to use his/her imagination based on the use case. But it's better to start after having a starting point which helps to avoid a lot of changes in later stages. For our use case, we can make a query to the above search query link to get the response and use a typescript generator tool like json2ts to get our typescript schema.
Note: If you are using JavaScript, you can skip this part but do take a look at the model once.
// src/containers/Search/types.ts
export interface Result {
title: string;
thumbnail: string;
permalink: string;
}
export interface SearchResults {
after: string;
dist: number;
modhash: string;
children: {
kind: string;
data: Result;
};
before?: any;
}
// reddit API response Model
export interface Search {
kind: string;
data: SearchResults;
}
We have defined a model called Search which represents the data sent from the Reddit search API. To keep it simple we've omitted attributes that are not used in the app. Result model represents each Reddit result.
We'll also add a SearchQuery interface in types.ts
where we will define query parameters required to make a Reddit search
// src/containers/Search/types.ts
... // Search Result model
export interface SearchQuery {
query: string;
limit: number;
};
Now let's define the redux state and actions types for Search container in types.ts
// src/containers/Search/types.ts
import { CustomError } from "../../utils/api-helper";
... // Search Result interface
... // Search Query interface
// Search action types
export enum SearchActionTypes {
GET_RESULTS_REQUEST = "@@search/GET_RESULTS_REQUEST",
GET_RESULTS_SUCCESS = "@@search/GET_RESULTS_SUCCESS",
GET_RESULTS_ERROR = "@@search/GET_RESULTS_ERROR",
}
interface Errors {
results: CustomError | null
}
// Search redux state
export interface SearchState {
isLoading: boolean,
results: Search | null,
errors: Errors
}
For search API requests there can only be 3 states at any given point of time .i.e.
- GET_RESULTS_REQUEST: while fetching results
- GET_RESULTS_SUCCESS: when we receive a successful response
- GET_RESULTS_ERROR: when we receive an error response
Similarly, for the Search container state we've defined
- isLoading: boolean to keep a track if any API request is being made or not
- results: where search results are going to be stored.
-
errors: where at most 1 error response for each attribute will be tracked (here we are tracking for
results
).
If you would have noticed we are using a pipe( | ) operator with null
type which means that at any given point it's value will be either of type T or null. We can also use undefined
but this way we'll need to always declare that attribute and assign a null value which in turn makes our code more readable.
Let's also add SearchState to the ApplicationState defined in src/store.ts
and call it search
// src/store.ts
... // imports
import { SearchState } from './containers/Search/reducer';
export type ApplicationState = {
search: SearchState
};
function configureAppStore(initialState: ApplicationState) {
... // store configuration
}
export { configureAppStore };
Let's define actions for Search state in redux. For this, we are going to use redux-toolkit's createAction
and createReducer
helper functions for actions and reducer respectively.
// src/containers/Search/action.ts
import { createAction } from "@reduxjs/toolkit";
import { CustomError } from "../../utils/api-helper";
import { Search, SearchActionTypes, SearchQuery } from "./types";
export const getResultsRequest = createAction<SearchQuery>(
SearchActionTypes.GET_RESULTS_REQUEST
);
export const getResultsSuccess = createAction<Search>(
SearchActionTypes.GET_RESULTS_SUCCESS
);
export const getResultsError = createAction<CustomError>(
SearchActionTypes.GET_RESULTS_ERROR
);
Here we have defined 3 action types. Since we are using Typescript, we have also defined the payload type for getResultsRequest
getResultsSuccess
and getResultsError
. The payload type will help connect the flow and avoid errors.
It's time to setup reducer for the Search state which will listen to dispatched action and if the action type matches, the redux state will be updated. To create the reducer, we are going to use the createReducer
helper utility from redux-toolkit using builder callback notation which is recommended with Typescript. For more information feel free to check the redux-toolkit docs.
// src/containers/Search/reducer.ts
import { createReducer } from "@reduxjs/toolkit";
import {
getResultsError,
getResultsRequest,
getResultsSuccess,
} from "./action";
import { SearchState } from "./types";
const initalState: SearchState = {
isLoading: false,
results: null,
errors: {
results: null,
},
};
const reducer = createReducer(initalState, (builder) => {
return builder
.addCase(getResultsRequest, (state, action) => {
state.isLoading = true;
state.results = null;
state.errors.results = null;
})
.addCase(getResultsSuccess, (state, action) => {
state.isLoading = false;
state.results = action.payload;
})
.addCase(getResultsError, (state, action) => {
state.isLoading = false;
state.errors.results = action.payload;
});
});
export { initalState as searchInitialState, reducer as searchReducer };
Here we are creating a reducer that will listen for SearchActionTypes created earlier and update state accordingly. Now for the sake of keeping this example simple, we are not considering pagination and other advance list operations. We'll assume that search results will be only fetched once and we'll keep data for the latest request therefore, we are resetting state when a new getResultsRequest
is made. We are also exporting the initial state (searchInitialState) which will also represent the search state when the application is bootstrapped.
NOTE: You can also use createSlice
method provided by redux-toolkit which will create both actions as well as a reducer for you. Action types can be provided inline. For more information, you can refer to redux-toolkit docs.
Now let's add the initial search state to the initial application state in src/App.tsx
// src/App.tsx
import React from 'react';
import { Provider } from 'react-redux';
import { ApplicationState, configureAppStore } from './store';
import { Navigator } from "./navigator/Navigator";
import { searchInitialState } from './containers/Search/reducer';
const initialState: ApplicationState = {
search: searchInitialState;
};
const store = configureAppStore(initialState);
function App() {
return (
<Provider store={store}>
<Navigator />
</Provider>
);
}
export default App;
We also need to add the search reducer in the root reducer by adding it to src/reducer.ts
// src/reducer.ts
import { combineReducers } from "@reduxjs/toolkit";
import { searchReducer } from './containers/Search/reducer';
const reducers = {
search: searchReducer
};
function createRootReducer() {
const rootReducer = combineReducers({
...reducers
});
return rootReducer;
};
export { createRootReducer };
Now when you run the application, you should be able to see a search
state available in the redux state.
The folder structure will look something like this
Now that we are done with the redux setup, it's time to setup saga middleware for the Search container. Let's start by creating a file saga.ts
in the Search container and define a getSearchResults
function which will listen for GET_SEARCH_RESULTS
action type. In order to understand how redux-saga work you can check out their official docs.
// src/containers/Search/saga.ts
import { all, fork, takeLatest } from "redux-saga/effects";
import { getResultsRequest } from "./action";
function* getSearchResults() {
// get search results API request
}
function* watchFetchRequest() {
yield takeLatest(getResultsRequest.type, getSearchResults);
}
export default function* searchSaga() {
yield all([fork(watchFetchRequest)]);
}
We have defined a searchSaga which we'll import in store.ts
so that it is registered. getSearchResults
will contain the code responsible for making an API request and depending on the response it'll dispatch a success or error action.
Before that, we'll need to first create a function for making API requests in src/services/Api.ts
. As mentioned above, to get search results from Reddit we can use the following endpoint and we'll pass the query
& limit
from the component.
https://www.reddit.com/r/all/search.json?q=<query>&limit=<limit>
We have already added the base URL (https://www.reddit.com) as API_ENDPOINT
in the environment configuration.
Let's define a function fetchSearchResults and we'll use the get
helper function from src/utils/api-helper.ts
.
// src/services/Api.ts
import config from "../config/app";
import * as API from "../utils/api-helper";
import { SearchQuery } from "../containers/Search/types";
const { isProd } = config;
const API_ENDPOINT = isProd
? config.production
: config.development;
export const fetchSearchResults = (params: SearchQuery) => {
const { query, limit } = params;
const url = `${API_ENDPOINT}/r/all/search.json?q=${query}&limit=${limit}`;
return API.get(url);
};
Now we can use fetchSearchResults, let's complete our search saga and make a get search API call.
Specifying the action as an argument to a saga is a bit tricky, we have to use TypeScript's Type Guards. Interestingly, it's mentioned in the redux-toolkit's documentation as well. In short, we have to use actionCreator.match method of the actionCreator to discriminate down the passed action to the desired type. Thus, after discrimination, we receive the desired static typing for the matched action's payload.
After playing around with the response, I ended up with the following saga.ts
.
// src/containers/Search/saga.ts
import { Action } from '@reduxjs/toolkit';
import { all, call, fork, put, takeLatest } from "redux-saga/effects";
import { getResultsError, getResultsRequest, getResultsSuccess } from "./action";
import * as Api from "../../services/Api";
import { getCustomError } from '../../utils/api-helper';
function* getSearchResults(action: Action) {
try {
if (getResultsRequest.match(action)) {
const res = yield call(Api.fetchSearchResults, action.payload);
const data = res.data;
if (res.status !== 200) {
yield put(getResultsError(data.error));
} else {
yield put(getResultsSuccess(data));
}
}
} catch (err) {
yield put(getResultsError(getCustomError(err)))
}
}
function* watchFetchRequest() {
yield takeLatest(getResultsRequest.type, getSearchResults);
}
export default function* searchSaga() {
yield all([fork(watchFetchRequest)]);
}
To register searchSaga, simply import it in root saga at src/saga.ts
.
// src/saga.ts
import { all, fork } from "redux-saga/effects";
import searchSaga from "./containers/Search/saga";
function* rootSaga() {
yield all([
fork(searchSaga)
]);
};
export { rootSaga };
This completes the data setup for the application. Now we can start with UI implementation. The folder structure will look something like this
Setting up the UI
We can divide the UI into 2 parts
- SearchInput: It'll have an input field which will take in search query from the user
- Results: Basically here we'll show results from the query
Let's create a folder called views
at src/containers/Search/views
where the above-listed components will go. The view
folder (sometimes named as screens
) inside the container will contain components that are specific to that container or accessing the global state (in our case redux state).
For sake of simplicity and since making components such as Input and Loader is outside the scope of this article, I'll be using a components library ant design. But in case you are wondering, components that might be used in multiple places stateless or otherwise will go inside the src/components
folder.
Though if you are using hooks, it might be a little difficult to decide where a component should go. In that case, as a thumb rule if a component is accessing the global state .i.e. from the redux store using useSelector
hook, then it should be listed under src/containers/{feature}/views
folder.
Let's add ant design component to the project
yarn add antd @ant-design/icons
Once the process is complete, we'll need to add ant design's CSS to /src/index.css
. Let's use the dark theme because well, who doesn't love a dark theme.
// src/index.css
@import '~antd/dist/antd.dark.css';
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
Let's create SearchInput component inside src/containers/Search/views
where user can search for a topic
// src/containers/Search/views/SearchInput.tsx
import React, { FC, useEffect, useState } from "react";
import { Avatar, Input } from "antd";
import logo from "../../../assets/logo.svg";
import "../styles.css";
import { useDispatch, useSelector } from "react-redux";
import { ApplicationState } from "../../../store";
import { getResultsRequest } from "../action";
type Props = {};
const { Search } = Input;
const SearchInput: FC<Props> = (props: Props) => {
const dispatch = useDispatch();
const [searchQuery, setSearchQuery] = useState("");
const [searchQueryLimit, setSearchQueryLimit] = useState(0);
const isLoading = useSelector<ApplicationState, boolean>(
(s) => s.search.isLoading
);
const onSearchQueryChangeHandler = (
e: React.ChangeEvent<HTMLInputElement>
) => {
const val = e.target.value;
setSearchQuery(val);
};
const onSearchHandler = () => {
dispatch(getResultsRequest({
query: searchQuery,
limit: searchQueryLimit
}))
}
useEffect(() => {
setSearchQueryLimit(25);
}, [])
return (
<div className="search-input-container">
<Avatar src={logo} shape="circle" size={150} />
<Search
className="search-input"
placeholder="Search for a topic"
loading={isLoading}
value={searchQuery}
onChange={onSearchQueryChangeHandler}
onSearch={onSearchHandler}
/>
</div>
);
};
export { SearchInput };
Let's start from the top, we have created a functional component SearchInput. We are using useSelector and useDispatch hooks to access redux state and dispatch redux actions. We are also using useState hook for managing search query and search query limit locally and useEffect to perform side effects in function components.
From the ant design components library, we have imported Avatar and Input.Search component. We have also defined some styles in src/containers/Search/styles.css
and also added Reddit logo SVG in src/assets
.
/* src/containers/Search/styles.css */
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.search-input-container {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.search-input {
margin: 2rem 0;
border-radius: 5px;
}
Now import SearchInput component in Search
// src/containers/Search/Search.tsx
import React, { FC } from "react";
import "./styles.css";
import { SearchInput } from "./views/SearchInput";
type Props = {};
const Search: FC<Props> = (props: Props) => {
return (
<div className="container">
<SearchInput />
</div>
);
};
export { Search };
Now hit save and let it compile then navigate to http://localhost:3000
you should be able to see something like this
Folder structure so far
Now let's work on the Results component which will show the results from the query. We'll add this component to the views
folder of the Search container.
Let's create a custom component called ResultListItem to display each result. Also, let's add an action type to reset the results which we can use to get back to the starting screen.
// src/containers/Search/types.ts
// ... SearchResults model
export interface Search {
kind: string;
data: SearchResults;
}
export interface SearchQuery {
query: string;
limit: number;
};
interface Errors {
results: CustomError | null
}
export enum SearchActionTypes {
GET_RESULTS_REQUEST = "@@search/GET_RESULTS_REQUEST",
GET_RESULTS_SUCCESS = "@@search/GET_RESULTS_SUCCESS",
GET_RESULTS_ERROR = "@@search/GET_RESULTS_ERROR",
**RESET_RESULTS = '@@search/RESET_RESULTS'**
}
export interface SearchState {
isLoading: boolean,
results: Search | null,
errors: Errors
}
Here we are adding a RESET_RESULTS
action type to src/containers/Search/types.ts
which will be used to reset results
state to null
in SearchState.
// src/containers/Search/action.ts
import { createAction } from "@reduxjs/toolkit";
import { CustomError } from "../../utils/api-helper";
import { Search, SearchActionTypes, SearchQuery } from "./types";
export const getResultsRequest = createAction<SearchQuery>(
SearchActionTypes.GET_RESULTS_REQUEST
);
export const getResultsSuccess = createAction<Search>(
SearchActionTypes.GET_RESULTS_SUCCESS
);
export const getResultsError = createAction<CustomError>(
SearchActionTypes.GET_RESULTS_ERROR
);
**export const resetResults = createAction(
SearchActionTypes.RESET_RESULTS
);**
Here we add a new action type resetResults, notice that we have not defined a return type as we have done for other actions? Since there's no value returned in resetResultst
there's no need to define an action type.
// src/containers/Search/reducer.ts
import { createReducer } from "@reduxjs/toolkit";
import {
getResultsError,
getResultsRequest,
getResultsSuccess,
resetResults,
} from "./action";
import { SearchState } from "./types";
const initalState: SearchState = {
isLoading: false,
results: null,
errors: {
results: null,
},
};
const reducer = createReducer(initalState, (builder) => {
return builder
.addCase(getResultsRequest, (state, action) => {
state.isLoading = true;
state.results = null;
state.errors.results = null;
})
.addCase(getResultsSuccess, (state, action) => {
state.isLoading = false;
state.results = action.payload;
})
.addCase(getResultsError, (state, action) => {
state.isLoading = false;
state.errors.results = action.payload;
})
.addCase(resetResults, (state, action) => {
state.results = null;
});
});
export { initalState as searchInitialState, reducer as searchReducer };
Adding a case for resetResults
in the reducer and set results
to null
.i.e. initial state.
Now let's create a Results component to display search results.
// src/containers/Search/views/Results.tsx
import React, { FC } from "react";
import { useDispatch, useSelector } from "react-redux";
import { ApplicationState } from "../../../store";
import { Search } from "../types";
import { ResultListItem } from "../../../components/ResultListItem/ResultListItem";
import logo from "../../../assets/logo.svg";
import { ArrowLeftOutlined } from "@ant-design/icons";
import { Button } from "antd";
import { resetResults } from "../action";
import "../styles.css";
type Props = {};
const Results: FC<Props> = (props: Props) => {
const dispatch = useDispatch();
const results = useSelector<ApplicationState, Search | null>(
(s) => s.search.results
);
const onResetResultsHandler = () => {
dispatch(resetResults());
};
return (
<div>
<div className="result-header">
<Button
icon={<ArrowLeftOutlined />}
shape="circle-outline"
onClick={() => onResetResultsHandler()}
/>
<div>Search Results</div>
<div />
</div>
{!results || results.data.children.length === 0 ? (
<div className="no-results-container">No results found</div>
) : (
<div className="results-container">
{results.data.children.map((result, index) => (
<ResultListItem
key={index}
title={result.data.title}
imageURL={result.data.thumbnail === "self" ? logo : result.data.thumbnail}
sourceURL={result.data.permalink}
/>
))}
</div>
)}
</div>
);
};
export { Results };
/* src/containers/Search/styles.css */
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.search-input-container {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.search-input {
margin: 2rem 0;
border-radius: 5px;
}
.result-header {
font-size: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
}
.result-header > i {
cursor: pointer;
}
.results-container {
max-width: 100vh;
max-height: 80vh;
overflow-y: scroll;
}
.no-results-container {
width: 100vh;
height: 80vh;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
Above we have defined a functional component called Results and the styles are defined in src/containers/Search/styles.css
. We are using hooks for getting and resetting redux state results
.
Let us now define ResultListItem component and it's styles in src/components/ResultListItem
. The pattern followed here is similar to that of the container. For a component that can be used in multiple places, we define it in a folder called components and create a folder with a component name that will contain it's component logic and styles.
// src/components/ResultListItem/ResultListItem.tsx
import React, { FC } from "react";
import "./styles.css";
import logo from "../../assets/logo.svg";
type Props = {
title: string;
imageURL: string;
sourceURL: string;
};
const ResultListItem: FC<Props> = (props: Props) => {
const { title, imageURL, sourceURL } = props;
const onClickHandler = (url: string) => {
window.open(`https://reddit.com/${url}`);
};
return (
<div className="item-container" onClick={() => onClickHandler(sourceURL)}>
<img className="thumbnail" alt="" src={imageURL} onError={() => logo} />
<div>
<div className="title">{title}</div>
</div>
</div>
);
};
export { ResultListItem };
/* src/components/ResultListItem/styles.css */
.item-container {
display: flex;
align-items: center;
padding: 0.5rem;
width: 100%;
height: 6rem;
border: 1px solid rgb(77, 77, 77);
margin-bottom: 0.5rem;
border-radius: 4px;
cursor: pointer;
}
.thumbnail {
width: 5rem;
border-radius: 0.2rem;
}
.title {
font-weight: bold;
padding: 1rem;
}
And make the following changes to Search container to display Results component if search results are present else show SearchInput component.
// src/containers/Search/Search.tsx
import { message } from "antd";
import React, { FC, useEffect } from "react";
import { useSelector } from "react-redux";
import { ApplicationState } from "../../store";
import { CustomError } from "../../utils/api-helper";
import "./styles.css";
import { Search as SearchModel } from "./types";
import { Results } from "./views/Results";
import { SearchInput } from "./views/SearchInput";
type Props = {};
const Search: FC<Props> = (props: Props) => {
const results = useSelector<ApplicationState, SearchModel | null>(
(s) => s.search.results
);
const searchError = useSelector<ApplicationState, CustomError | null>(
(s) => s.search.errors.results
);
useEffect(() => {
if (searchError) {
message.error(searchError.message);
}
}, [searchError]);
return (
<div className="container">{!results ? <SearchInput /> : <Results />}</div>
);
};
export { Search };
Finally, your project structure should look something like this with all the above changes
Once all the above changes are saved, the project should compile and you should be able to search for a topic and see results as shown below
You can refer to the following repository for the final code.
anishkargaonkar / react-reddit-client
Reddit client for showing top results for given keywords
Closing thoughts
In this 2 part series, I've tried to define a structure that has worked for me with mid/large scale projects where debugging bugs, adding new features with the ever-changing scope was easy and manageable both in React and React-Native. Though there's no perfect structure that works for all, this can be a good starting point.
I hope you enjoyed reading the article as much as I enjoyed writing it. Would love to hear your thoughts about it. Adios!
Top comments (2)
Wow! Great tips.
I would also like to suggest Absolute imports because that makes the imports a lot easier.
Great suggestion 👍