ReactJS project based training with Road Trip Planner App

Integrating Local Storage to our app

Idea: localStorage is needed on the entire planning page, inside MapsBoard we need to store the data received from the input fields, and on the planning-board we need to retrieve that data to show it to the user by passing it to DestinationCard, so I guess I made my point?

We add local storage to the <Planning/> component! and the method making use of localStorage are also to be defined here.

we’ll do this in the following steps,

  • add a state variable to <Planning/>
state = { locations: [] };

This state variable locations will always be in sync with the locations array that is stored in the localStorage.

But why do we need to create and keep track of locations as a state variable?

Whenever anything gets added or removed from locations, it should reflect on the page. It means that the TripBoard component should re-render, which contains cards corresponding to each location object inside the locations array. Here, as the locations are inside the state variable of <Planning/>, the whole planning component will re-render on any change in the locations array, and thus <TripsBoard/> being a part of it also re-renders. So it will immediately show the added cards and will pop the removed cards.

You might think, if we want to re-render just the TripBoard component then why not add locations to the state variable of TripBoard, but you see we will be connecting this state variable to the locations inside local storage, and also to the methods updating and accessing local storage, which are to be passed inside TripBoard as well as MapsBoard, you’ll see in a few minutes.

  • add lifecycle method componentDidMount()
componentDidMount() {
		//retrieve the existing locations data from localStorage
    var Locations = JSON.parse(localStorage.getItem("locations"));
    if (Locations) {
      this.setState({ locations: Locations });
    } else {
      localStorage.setItem("locations", JSON.stringify(this.state.locations));
    }
  }

You must remember from the previous article where we discussed lifecycle methods, that this function runs after the very first render. As soon as the elements of the Planning page are rendered on the website, locations from the local storage is retrieved and stored inside the state variable, if there is no such object stored (which is the case for first-time users), it’ll instead store the empty locations array from the state variable into the localStorage.

In simple terms it kind of works like this - if locations in localStorage exists then copy it into locations state variable, and if it doesn’t exist then copy locations state variable into localStorage, thus they both are in sync as I mentioned earlier.

  • write the add_location() method
add_location = (location) => {
    //it's a regex looking for a line start (^), followed by zero or more whitespace (\s*) characters, followed by a line end ($).
    if (!location.name || /^\s*$/.test(location.name)) {
      return;
    }
    const newlocations = [...this.state.locations, location];
    localStorage.setItem("locations", JSON.stringify(newlocations));
    this.setState({ locations: newlocations });
  };

this method receives a location object (single location, a user input) as an argument,

and if location.name doesn’t exist (user just clicks on add button without even touching the location field) or if it is an empty string (checks with regex), then nothing happens, else, the current state locations and the newly received location is merged using a spread operator to form a newlocations object, which is then updated inside both localStorage and then the state variable.

Remember that we need to keep updating both the localStorage and state.locations, first in order to store the updated information, and later in order to show (re-render) the updated information.

Note that the order of updation matters as well, as we first need to store the updated information and then show it on the page by triggering a re-render using state-updation.

  • write the remove_location() method**
remove_location = (id) => {
    const removedLocations = [...this.state.locations].filter(
      (location) => location.id !== id
    );

    localStorage.setItem("locations", JSON.stringify(removedLocations));
    this.setState({ locations: removedLocations });
  };

For the input of this method, we pass the id of the location we need to remove. Using that id we filter out all of the locations which do not have the same id as the passed id, and those filtered locations are stored inside removedLocations array. Now just update localStorage and then state.locations with this new array, and we’re done!

Now let’s put it all together, and your Planning.jsx should look something like this,

import React from "react";
import "./planning.css";
import TripBoard from "./../components/TripBoard";
import MapsBoard from "../components/MapBoard";

class Planning extends React.Component {
  state = { locations: [] };

  componentDidMount() {
    var Locations = JSON.parse(localStorage.getItem("locations"));
    if (Locations) {
      this.setState({ locations: Locations });
    } else {
      localStorage.setItem("locations", JSON.stringify(this.state.locations));
    }
  }
  
  add_location = (location) => {
    //it's a regex looking for a line start (^), followed by zero or more whitespace (\s*) characters, followed by a line end ($).
    if (!location.name || /^\s*$/.test(location.name)) {
      return;
    }
    const newlocations = [...this.state.locations, location];

    localStorage.setItem("locations", JSON.stringify(newlocations));
    this.setState({ locations: newlocations });
  };

  remove_location = (id) => {
    const removedLocations = [...this.state.locations].filter(
      (location) => location.id !== id
    );

    localStorage.setItem("locations", JSON.stringify(removedLocations));
    this.setState({ locations: removedLocations });
  };

  render() {
    return (
      <div className="planning-container">
        <div className="planning-board">
          <h1>Create a new Trip</h1>
          <TripBoard
            locations={this.state.locations}
            remove_location={this.remove_location}
          />
        </div>
        <MapsBoard add_location={this.add_location} />
      </div>
    );
  }
}
export default Planning;

👆 Planning.jsx

Now let’s see what we changed in the view part of this component -

  • we passed the locations (state variable) to the <TripBoard/> as locations prop
  • we passed the remove_location as functional prop named remove_location to the <TripBoard/>, note that I kept the same name for the prop as the method, you can have a different prop name for it, but use this same name while accepting it inside <TripBoard/>
  • we passed the add_location method to <MapsBoard/> as a functional prop

I tried to depict all the parts of the Planning component by connecting them with each other, please take a look and see if I made it any easier for you 😀

Bonus: Now, if you remember the topic unidirectional data flow from the intro article, you will be able to relate how child components <TripBoard/> and <MapsBoard/> are making changes to the state of their parent <Planning/>, by using the functional props. I mean using the simple props (locations) we pass data from parent to children (<Planning/> to <TripBoard/>), but using the functional props (remove_location, add_location), child components <TripBoard/> and <MapsBoard/> will be communicating back to parent <Planning/>, by adding data to its state.

⚠️ Note: ALWAYS use arrow functions for methods you need to pass as function props, and if you are using any class fields inside it, because when this function is passed as a prop to another component, inside that another component context changes hence "this" might be undefined or something else. So for example if you are using a normal function and not an arrow function, this.state.locations of Planning will be inaccessible from inside TripBoard, and TripBoard couldn’t make any changes to this.state.locations. It might sound a little vague right now, to know more about this you can read about static binding in arrow functions and “this” variable.

<TripBoard/>

Now let’s replace the demo_props with real props in <TripBoard/> as we have it available now,

import DestinationCard from "./DestinationCard";

const TripBoard = (props) => {
  return (
    <div className="trip-board">
      {props.locations.map(({ id, name, date, time }) => (
        <DestinationCard
          key={id}
          remove_location={props.remove_location}
          id={id}
          name={name}
          date={date}
          time={time}
        />
      ))}
    </div>
  );
};
export default TripBoard;

TripsBoard.jsx

Also remove the empty remove_location function, as we have got the real one as a prop. <DestinationCard/> inside it is all right, we don’t need to change anything in that.

TripsBoard is working fine, showing all the DestinationCards, I can say that because I have the locations object stored in my localStorage from previous trials. For you, TripBoard must be empty as you don’t have a locations array stored in your browser.

Let’s fix the MapsBoard component now,

import DestinationForm from "./DestinationForm";
import Logo from "./logo";
import svgs from "../assets";

const MapsBoard = ({ add_location }) => {
  return (
    <div className="maps-board">
      <Logo />
      <h1>Pick and add your destinations</h1>
      <DestinationForm add_input={add_location} />
      <img src={svgs.destination} alt="picking up from map" className="map-img" />
    </div>
  );
};

MapsBoard.jsx

MapsBoard accepts add_location() now, and passes it on to the <DestinationForm/> as a functional prop named add_input, notice how add_location() has been traveling down from Planning to DestinationForm, and DestinationForm will still be able to use this method and make changes to the state variable of Planning component, by adding new locations to it. Try to relate this to the unidirectional data flow we discussed in the last article. Let’s see what changed in this component,

  • locations array has been passed as a props object from Planning to TripBoard to DestinationCard.
  • add_location() has been passed from Planning to MapsBoard to DestinationForm as add_input

Try adding a new location on your web app, it still doesn’t work right? Now let’s see what we need to fix inside DestinationForm, in order to make it work.

But first go over the concept of hooks in react, because this time we’ll use a functional component, and in functional component state variables are maintained with the useState hook.

State variables

You must remember the useState hook from the State section in our Introduction to React blog. Note that here we need to keep track of all three input fields, and we’ll do that using the state variables. It might sound a little confusing but bear with me for a minute,

See using the state variables we need to achieve the two following tasks,

  1. add the user input to the localStorage using the received method add_input
  2. after the user hits the Add button to submit the input, all of the fields should get cleared (altho it’s not necessary but an essential feature)

Why state variables, what do we re-render this time?

For the above mentioned second task, we need to re-render the input fields as empty.

We will have one state variable for each input field,

  const [input, setInput] = useState("");
  const [inputDate, setInputDate] = useState("");
  const [inputTime, setInputTime] = useState("");

Recap: input is the state variable, setInput is the function to update the respective state variable.

With the help of the onChange event handler, we’ll keep updating the state variable with whatever user types in the input field, and onSubmit the value of these state variables will be passed inside add_input

<input
          type="text"
          name="destination"
          className="input-field"
          placeholder="City, State"
          onChange={(e) => setInput(e.target.value)}
        />

input field for location name

Now please pay attention, to empty the input field from inside the component, we need to control the input field somehow, right?

To do that, we can set the value attribute of the input field to a state variable, and now the input field will show whatever content its corresponding state variable holds, on every update to the state variable content inside input fields will also change (remember what triggers the re-render).

<input
          type="text"
          name="destination"
          className="input-field"
          placeholder="City, State"
          onChange={(e) => setInput(e.target.value)}
          value={input}
        />

input field for location name

onSubmit handler:

So when the user hits the add button, we’ll add the content of the state variables to the localStorage by passing them inside the add_input and right after that we’ll update the state variables of all three input fields with empty strings, it’ll re-render the input fields as empty.

We’ll achieve this with an event handler method handleSubmit, and don’t forget to pass it to the onSubmit attribute inside <form/>.

const handleSubmit = (e) => {
    e.preventDefault();
    //add_locations used here to add a new location to the state variable of Planning and localStorage
    props.add_input({
      id: Math.floor(Math.random() * 1000), //to calculate a random 3 digit no. for id
      name: input,
      date: inputDate,
      time: inputTime,
    });

    //Empty out the input fields after submission
    setInput("");
    setInputDate("");
    setInputTime("");
  };

Function to handle onSubmit event

Disable the past dates:

When a user selects the date of the departure for a particular location, it should be a future date only, for that we need to set the minimum date to be accepted with the user’s current date,

  var dtToday = new Date();
  var month = dtToday.getMonth() + 1; //javascript starts month from 0 to ... 11
  var day = dtToday.getDate();
  var year = dtToday.getFullYear();
  if (month < 10) month = "0" + month.toString();
  if (day < 10) day = "0" + day.toString();

  var minDate = year + "-" + month + "-" + day;

Finally the <DestinationForm/> should look like,

import { AiOutlinePlus } from "react-icons/ai";
import { useState } from "react";

export default function DestinationForm(props) {
  const [input, setInput] = useState("");
  const [inputDate, setInputDate] = useState("");
  const [inputTime, setInputTime] = useState("");

  var dtToday = new Date();
  var month = dtToday.getMonth() + 1; //javascript starts month from 0 to ... 11
  var day = dtToday.getDate();
  var year = dtToday.getFullYear();
  if (month < 10) month = "0" + month.toString();
  if (day < 10) day = "0" + day.toString();
  var minDate = year + "-" + month + "-" + day;

  const handleSubmit = (e) => {
    e.preventDefault();
   
    props.add_input({
      id: Math.floor(Math.random() * 1000),
      name: input,
      date: inputDate,
      time: inputTime,
    });
  
    setInput("");
    setInputDate("");
    setInputTime("");
  };

  return (
    <form onSubmit={handleSubmit}>
      <div className="input-fields-container">
        <input
          type="text"
          name="destination"
          className="input-field"
          placeholder="City, State"
          onChange={(e) => setInput(e.target.value)}
          value={input}
        />
        <input
          type="date"
          name="destination"
          className="input-field"
          placeholder="Pick a Date"
          min={minDate}
          value={inputDate}
          onChange={(e) => setInputDate(e.target.value)}
        />
        <input
          type="time"
          name="destination"
          className="input-field"
          placeholder="Time you start your journey"
          value={inputTime}
          onChange={(e) => setInputTime(e.target.value)}
        />
      </div>

      <button className="add-btn" type="submit">
        Add <AiOutlinePlus />
      </button>
    </form>
  );
}

DestinationForm.jsx

And it’s done, everything is working now,

Except, for the StartButton on the Homepage for which we need to make sure that both Home.jsx and Planning.jsx acts as two different pages. We’ll achieve that by making both pages two separate routes, in the next section.