This is part 2/5 of the full-stack application series! Previously we provided an overview of the full-stack application. In this post, we will cover some non-beginner aspects of React that I encountered over the course of this project.
Table of Contents
- Pre-requisite Knowledge
- Dynamic URL Paths
- Making API Calls with Axios
.env
for different environments- React Hooks
- LocalStorage
- Rendering Latex using
react-latex
- Promises
Pre-requisite Knowledge
As mentioned in the previous post, I won’t go over all the basics of React so if you have never worked with React before, you can watch a tutorial on Youtube. I highly recommend Traversy Media’s tutorial. 😜
Dynamic URL Paths
React is a framework suited for creating single-page applications (SPA). This means that in its most basic form, a website powered by React will only contain a single web page and any changes will be dynamically loaded by updating the “state” of that web page.
However, sometimes we need multiple “pages”. We can achieve this by using a package called react-router-dom
. It’s pretty simple to use. Let’s take a look.
// ./src/App.js
import {BrowserRouter as Router, Switch, Route} from "react-router-dom";
import LoginPage from "./page/login";
import LogoutPage from "./page/logout";
import TestPage from "./page/test";
import HomePage from "./page/Home";
function App() {
return (
<Router>
<div>
<Switch>
<Route path="/login" component={LoginPage}/>
<Route path="/logout" component={LogoutPage}/>
<Route path="/test/:testId/" component={TestPage}/>
<Route path="/" exact component={HomePage}/>
</Switch>
</div>
</Router>
);
}
The Router
component sends the user to the first path
that it matches on. The page rendered is defined in component
. You can use exact
to only redirect a user if the path matches exactly.
Another thing to note is that you can pass parameters into the url and retrieve them in the component that is rendered. We do this using the useParams()
hook. So for example if the user is sent to /test/3
then we can do the following:
// ./src/page/test.js
import { useParams } from "react-router-dom";
const TestPage = () => {
// Has to exactly match the parameter specified in App.js
let { testId } = useParams();
console.log(`testId: ${testId}`);
// Return the test page UI components here.
return (
<>
...
</>
)
};
export default TestPage;
When the page is being loaded, it should print out 3
into the console.
The last thing that is commonly used is to re-direct users to paths via some control mechanism. We use the useHistory()
hook.
import React from 'react';
import Button from '@material-ui/core/Button';
import { useHistory } from 'react-router-dom';
const StartTest = observer( => {
const history = useHistory();
return (
<Button
// Take them to test 3.
onClick={() => history.push('/test/3/') }
>
Start quiz
</Button>
);
});
export default StartTest;
In this component when the user clicks the button, we send them to test 3.
Making API Calls with Axios
Most frontend applications need a way to grab data from the backend. fetch
and axios
are common interfaces to use. For my project I chose axios
.
Here is a simple example below.
var axios = require('axios');
axios({
method: 'get',
url: `/test/3/`,
headers: { }
})
.then((response) => {
// Save the questions of the test
this.questionIds = response.data.questions;
})
.then(() => {
// There is a pointer to -1, move to zero i.e. get first question
this.getNextQuestion()
})
.catch((error) => {
// Handle any errors... maybe don't just print it out ;)
console.log(error);
});
This code structure
func()
.then()
...
.catch()
;
is a standard thing in javascript these days. Because it’s such standard practice sometimes you just forget that this is just a Promise
under the hood running async methods. So if you have 2 of these running, you can’t actually force one to depend on another because the code doesn’t necessarily finish executing from top to bottom.
We’ll talk about Promises
a little bit later.
.env
for different environments
When I defined the url
for my axios
call, I only used the last part of the API /test/3/
instead of writing out the full URL e.g. localhost:8000/test/3/
.
To make the code more flexible to local and remote deployments, I used axios.defaults
like below:
// ./src/index.js
import axios from 'axios';
axios.defaults.baseURL = process.env.API_ENDPOINT || 'http://localhost:8000/api';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
Here, we use the inbuilt process
variable to grab the endpoint appropriate for the environment we’re deploying in.
I’m not sure of how companies with more complicated deployment requirements handle it, but I found that the env-cmd
package (see here) **was good enough for my needs.
All I have to do is create multiple .env
files e.g.
# ./.env.dev
API_ENDPOINT=app-dev.com
# ./.env.prod
API_ENDPOINT=app.com
and then use env-cmd
to load the appropriate script at build time.
# E.g. for dev deployment
env-cmd -f .env.dev react-scripts start
Just tack env-cmd -f <.env file>
at the front of your build scripts and you should be able to switch env
variables for a given environment.
React Hooks
There are so many useful React hooks and over the course of the project I ended up using 3 of them - useState
, useContext
and useEffect
.
useState
is something that everyone would come across in their first React tutorial, so I won’t discuss it further here.
In this tutorial, I’ll talk about useContext
and useEffect
.
React hook: useContext()
If you would like to pass around information that should persist across the entire application, then useContext
is a useful “component” to have.
// ./src/StudentContext.js
import React from 'react';
export const StudentContext = React.createContext({});
export function StudentInfoProvider ({ children }) {
const studentContext = {
student_id: localStorage.getItem('student_id'),
student_name: localStorage.getItem('student_name'),
}
return (
<StudentContext.Provider value={studentContext}>
{children}
</StudentContext.Provider>
)
}
Here, we create a component that passes the studentContext
to all child components. You can pass a single variable of course, but most likely you’ll want to persist multiple pieces of information. You’ll be able to achieve that with a javascript object (or dictionary).
In order to actually get this information into the application, you’ll have to wrap the entire application with it:
// ./src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { StudentInfoProvider } from './StudentContext'
import axios from 'axios';
axios.defaults.baseURL = process.env.API_ENDPOINT || 'http://localhost:8000/api';
ReactDOM.render(
<React.StrictMode>
<StudentInfoProvider>
<App />
</StudentInfoProvider>
</React.StrictMode>,
document.getElementById('root')
);
So now we’re created this context in the background. To access it, we’ll use
// ./src/page/<some file>
import React from "react";
import { StudentInfoProvider } from "../StudentContext";
const TestPage = () => {
const studentContext = React.useContext(StudentInfoProvider);
return (
<div>
<Navbar />
<div>
<p>Student id: {studentContext.student_id}</p>
</div>
</div>
);
};
export default TestPage;
React hook: useEffect()
The useEffect()
hook is very simple to use in practice, but there are a few concepts that justify the need for this hook.
To simplify the use cases, we will just say that you might consider using this when you need a space for non-component rendering logic. Some tutorials explain this as side-effects. Examples of this are API calls and setting timer functions (we use both in our application).
At first, I was confused by this, but essentially rendering a table e.g. the design of the columns and rows, etc. is component related, but the data that populates the table is not - it may change if you apply filters, render the next page.
The useEffect
hook takes in a callback and array of dependencies.
// ./src/pages/TestTable.js
import React from "react";
import { HomePresenter } from "../HomePresenter";
const TestTablePage = () => {
// We have a store object that holds all
// logic to reach server-side data
const store = new HomePresenter(userContext, parseDateObj);
React.useEffect(() => {
// Insert side-effect logic here.
// Make API calls to fetch the tests
// to populate the table
store.getTests();
}, []);
// Table component to populate test data
return (
<div>
<Navbar />
<div>
<Table store={store}/>
</div>
</div>
);
};
export default TestTablePage;
We see how the hook fits inside a component. Let’s just discuss the hook itself.
React.useEffect(() => {
// Insert side-effect logic here.
// Make API calls to fetch the tests
// to populate the table
store.getTests();
}, []);
There are 2 things I would like to point out. The first is that it is possible to return function in this hook. E.g.
React.useEffect(() => {
store.getTests();
return () => { store.deleteTests() };
}, []);
Here, we ensure that any tests in the table are removed before re-populating the component. This is called a “cleanup”, which is a function that is run before the next useEffect
is executed when the component is updated.
The 2nd thing I wanted to point out is that we can specify dependencies that will re-trigger useEffect
only when it changes.
React.useEffect(() => {
store.getTests();
return () => { store.deleteTests() };
}, [table]);
By adding the dependency above, we tell React to only re-apply the effect ONLY when the values specified (in this case table) change.
I didn’t need any of these more advanced cases in my application. However, I did learn about them through reading a few tutorials.
LocalStorage
I used localStorage
in the browser to store the user information so that I wouldn’t have to log in again. Now, this might not be the most secure thing but if you wanted to save some time when you’re developing locally… 😃
async function login() {
axios({
method: 'post',
url: '/user/',
data: userCredentials // obtain this somehow :)
})
.then((res) => {
// First remove cached data
localStorage.removeItem('first_name');
localStorage.removeItem('last_name');
// Then store it locally
localStorage.setItem('first_name', res.data.first_name);
localStorage.setItem('last_name', res.data.last_name);
// You can set the token here
// We will talk about token authentication in part 3.
// when we discuss how we built the backend using Django.
axios.defaults.headers.common['Authorization'] = `Token ${res.key}`;
})
.catch((err) => { console.log(err); }
}
Rendering Latex using react-latex
Since there were math equations involved, we also investigated different ways of rendering latex in the browser. This is not the final solution we used due to some other complications, but we found this to be reliable when we tried it.
The documentation contains all the examples you’ll need to so I won’t talk about it more.
Promises
We’ve covered a number of things that I’ve encountered during my project, but I’ve saved the best till last. I’m not going to do a deep dive into Promises
as there are plenty of tutorials that discuss this in-depth. What I will do, to provide 2 cases of where I had to use Promises
to solve my problems. In both cases, I needed to make the async function synchronous - so that was fun! 😛
Wait for async function in another component
So when a student completes a test, in order to submit it they need to click a “Confirm” button. We then make an API call inside the store
class, which holds all the logic and data storage.
Since a student can attempt a test multiple times, we need to wait for the API to return the attempt number so that we redirect the user to the results page.
However, since it is asynchronous, we can’t retrieve the attempt number in time if we set it up this way:
// ./src/pages/SubmitButton.js
const redirectToResults = () => {
store.submitAssessment();
// This runs before the API call returns the result!
// In most cases, this results in a race condition.
history.push(`/result/${store.testId}/${store.attemptNumber}/`);
})
}
// ./src/pages/TestPresenter.js
async submitAssessment() {
axios({
method: 'post',
url: `/${this.testType}/submit/`,
headers: { },
data: {
test_id: this.testId,
student_answers: this.responses,
}
})
.then((response) => {
this.attemptNumber = response.data.attempt_number;
})
.catch((e) => {
console.log(e);
})
}
Instead, we’ll need to wrap the axios
call inside a new Promise
and wait for it to resolve before redirecting the user.
// ./src/pages/SubmitButton.js
const redirectToResults = () => {
// Here, we wait for the test to be stored
// and the attempt number to be returned.
store.submitAssessment().then((attemptNumber) => {
history.push(`/result/${store.testId}/${store.attemptNumber}/`);
})
}
// ./src/pages/TestPresenter.js
// It's no longer an async function
submitAssessment() {
return new Promise((res, _) => {
axios({
method: 'post',
url: `/${this.testType}/submit/`,
headers: { },
data: {
test_id: this.testId,
student_answers: this.responses,
}
})
.then((response) => {
res(response.data.attempt_number);
})
.catch((e) => {
console.log(e);
})
});
}
And just like that, we can force the function to resolve first before we continue.
Waiting for multiple async functions
If you want wait to wait for multiple async functions, just use Promise.all()
:
// I use this inside my store to get the
// result and responses of the student.
// I stored them separately in the database
// so that's why 2 calls were necessary.
Promise.all([
this.fetchAttemptResult(),
this.fetchAttemptResponses()
])
.then((res) => {
const testResult = res[0].data;
const testResponses = res[1].data;
...
})
.catch((e) => {
console.log(e);
})
Note that this is different from wrapping a new Promise()
around a call. You can wrap new Promise()
around this Promise.all()
and return a singular result just like before. However, I didn’t need to do that thankfully!
Trailer
In the next installment, we will talk about building the backend using Django + DRF.
The post will include:
- Modelling the data requirements
- Writing API calls
- Configuring user permissions
- Token authentication
- Setting files for
dev
andprod
deployment
UPDATE: You can read part 3 here