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

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 and prod deployment

UPDATE: You can read part 3 here