Topic 7: Further React

Components containing other components, and sharing state ("lifting state up")

More complex components will contain sub-components. For example, imagine an extended version of the shopping cart in which there are two React components, one for adding a product, and one for showing the list of products.

Why would you want to do this? You might want to use the component containing the shopping cart in another application, where the products are loaded in from the web rather than input by the user, for example. Or you might want the "add product" component to connect to a database and store the tasks in a database, rather than displaying them immediately.

So by separating out the component into smaller, separate components, it makes each component reusable in different situations, and you can use one component without the other.

What you are probably asking is: how can the contents of the first sub-component (to add a product) be passed to the second (the list of products)? The recommended approach (see the React documentation) is to store information that needs to be shared between the two components as state of the parent. This is known as lifting state up (i.e. up to the parent component).

This topic is discussed in the React documentation

Lifting state up - a simple example

We will illustrate this with a simpler example. This example contains three components:

AppComponent

import React from 'react';

function AppComponent({title, defaultName}) {
    const [name, setName] = React.useState(defaultName);


    function updateState(name) {
        setName(name);
    }

    return (
        <div>
        <InputComponent title={title} passBackUserInput={updateState} />
        <GreetingComponent name={name} />
        </div>
    )

}
export default AppComponent;

InputComponent

import React from 'react';

function InputComponent({title, passBackUserInput}) {


    function updateName() {
        const n =  document.getElementById('name').value;
        passBackUserInput(n);
    }

    return (
        <div>
        <h1>{title}</h1>
        <fieldset>
        <input type='text' id='name' onChange={updateName} />
        </fieldset>
        </div>
    )
}
export default InputComponent;

GreetingComponent

import React from 'react';

function GreetingComponent({name}) {
    return (
        <div>{name}</div>
    )
}

export default GreetingComponent;

What is happening here? The diagram gives a general idea:

Lifting up state

Firstly note the parent component, AppComponent. Note that the parent component stores the name in its state. Note how the name is passed down to the two sub-components, InputComponent and GreetingComponent, as their props. In a similar way to the text field value being tightly bound to the state, this will tightly bind the props of the sub-components to the state of the parent.

In the GreetingComponent we display the name prop, which as we have seen, is tightly bound to the state of the parent because it's passed down from the parent.

The important thing with the InputComponent is that it needs a way to send the name the user entered back up to the AppComponent, because that is where the name is being stored in the state. To do this, we pass a method to update the state into the InputComponent, which can then call it. We do this by passing the updateState() method of AppComponent (which updates the state) into the InputComponent as a prop called passBackUserInput.

 <InputComponent passBackUserInput={updateState} name={name}/>

Thus, this prop, i.e. passBackUserInput(), can be called as a method from the InputComponent. This is precisely what happens when we encounter an onChange event; we first call the InputComponent's own updateName() and then pass the name in the text field to passBackUserInput(). Since the passBackUserInput() property is set equal to the updateState() method in AppComponent, any change to the name in the text field will update the state of the parent component. Then, since the name prop of the GreetingComponent is tightly bound to the parent's state, any updates in the text field will update the GreetingComponent.

Refs - preserving data between renderings

React documentation: here

Sometimes it might be desirable to preserve data between renderings, without using state. State is intended for data that will be rendered (hence, updating state triggers a re-rendering) but there are some cases where we might wish to preserve data for other reasons. For example, we might want to keep hold of a timer handle used to control a timer function. Or we might want to use objects which are part of other APIs, such as mapping APIs (e.g. Leaflet).

We could use global variables, declared outside the component, for this in theory. However this is not considered good design as components are supposed to be pure functions (ref React docs) which perform a task (rendering a component) without having any side-effects (e.g. changing variables external to the component).

Instead we can use a ref (reference). A ref is a variable that can be changed within the component without causing a re-rendering.

A good example to store in a ref might be a timer variable. You might know that you can use setInterval() in JavaScript to run code every so many milliseconds, e.g.


const startTime = Math.round(Date.now() / 1000);

const timerHandle = setInterval( () => {
    const timeNow = Math.round(Date.now() / 1000);
    document.getElementById('seconds').innerHTML = `${timeNow - startTime} seconds have passed.`;
}, 1000);

In the example above the arrow function which displays the number of seconds that have passed, by subtracting the initial time from the current time. Date.now() gives the number of milliseconds since January 1st 1970, so we divide by 1000 and round to the nearest integer to convert this to seconds. The arrow function will run every second (1000 milliseconds). Note also how setInterval() returns a handle on the timed function (stored in the variable timer here) which can be used later to cancel the timer:

clearInterval(timerHandle);

So how can we do this in React? We can store the timer as a ref, because we need to preserve it between renderings. For example:

import React from 'react';

function Timer() {

    const timerHandle = React.useRef(null);
    const [startTime, setStartTime] = React.useState(0);
    const [currentTime, setCurrentTime] = React.useState(0);


    function startTimer() {
        // If the timer is currently running...
        if(timerHandle.current == null) {

            // Store the start time in state
            const initialTime = Math.round(Date.now() / 1000);
            setStartTime(initialTime);
            setCurrentTime(initialTime);

            // Start the interval function
            timerHandle.current = setInterval ( () => {
                // Every second, store the current time in state
                const timeNow = Math.round(Date.now() / 1000);
                setCurrentTime(timeNow);
            } , 1000 );
        }
    }

    function stopTimer() {
        if(timerHandle.current != null) {
            clearInterval(timerHandle.current);
            timerHandle.current = null;
        }
    }

    // Render the number of seconds that have passed by subtracting the
    // current time from the start time
    return( <div>
        Time: {currentTime - startTime} seconds <br />
        <input type='button' value='start' onClick={startTimer} />
        <input type='button' value='stop' onClick={stopTimer} />
        </div>
    );
}

export default Timer;

What's this doing?

Here is another example of using a ref, to hold a Leaflet map. Note that React.useEffect() sets up an effect, which will be discussed below. An effect is a function which can run either on every render or on start-up only - see below for more details.

import React from 'react';
import 'leaflet';

function LeafletMap({lat1, lon1}) {
    const map = React.useRef(null);
    const [lat, setLat] = React.useState(lat1 || 51.05);
    const [lon, setLon] = React.useState(lon1 || -0.72);

    React.useEffect( ()=> {
        // Initialise the map ref if it hasn't been initialised already
        if(map.current === null) { 
            map.current = L.map("map1");
            // Set the map up in the normal way
            L.tileLayer
            ("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
                { attribution: "Copyright OSM contributors, ODBL" } ).addTo(map.current);
            const pos = [lat, lon];    
            map.current.setView(pos, 14);

            // Handle the map moveend event by updating the state appropriately
            map.current.on("moveend", e=> {
                const centre = map.current.getCenter();
                setLat(centre.lat);
                setLon(centre.lng);
            });
        }

    });


    return(
        <div>
        Lat: <input id='lat' />
        Lon: <input id='lon' />
        <input type='button' value='go' onClick={setPos} />
        <p>Map centred at: {lat} {lon}</p>
        <div id="map1" style={{width:"800px", height:"600px"}}></div>
        </div>
    );
    
    function setPos() {
        const lt = document.getElementById('lat').value;
        const lng = document.getElementById('lon').value;
        setLat(lt);
        setLon(lng);
        map.current.setView([lt, lng], 14);
    }
}

export default LeafletMap;

In this example, the current map latitude and longitude are stored in state and displayed. Also, when the user clicks the button to change the location, the map moves to that location.

The interesting thing, though, is that the map is stored as a ref. It's not a state variable to be rendered, it's an external entity to React which needs to be kept around between renders. Refs are a good way of handling such external objects. So we initialise it as a ref, and then update the position of the map using that ref in setPos().

We also handle map moveend events (when the user stops dragging the map) so that the lat and lon state variables are updated to hold the new centre position of the map.

Finally you will note that the map initialisation code is written inside an arrow function passed to something we have not discussed yet: React.useEffect(). What is this? The next section will explain!

Effects

React documentation: here

Commonly we might want some non-React code to run each time we render. For example, we might wish to fetch data from an external web API. We cannot do this directly inside the component function, as the component function is intended for rendering only; it should execute quickly and not contain time-consuming code. We could use an event handler (e.g. a button click handler), to perform these kinds of operations, but in some cases we might want the action to occur without such an event, e.g. when the component first loads or each time we render.

Instead, we can create an effect. An effect is a function which we might want to run each time the component renders, which performs some kind of external operation. We specify effects with React.useEffect(), passing in a function as a parameter. An example is given above in the mapping component. Note that the code to initialise the map is placed within an arrow function passed as a parameter to React.useEffect().

Note how in our effect, we check that the map (a ref) is null. If it's null, it means we haven't initialised it yet so we initialise it. If not, we do nothing. Effects run each time the component is rendered, so we have to ensure that we do not re-initialise the map each time the component is rendered, which would be very wasteful and would also constantly reset the map to its default location.

Effects and async/await

You cannot make an effect an async function. If you wish to do asynchronous tasks (e.g. AJAX) from an effect directly, you should use the then-based syntax for promises instead. See WAD week 3.

Running effects just once - on initialisation

The example above showed one way to prevent effects running on each render, by setting up a ref and checking whether it's null. However, there is a second, automatic, way of doing this. We can pass an empty array as the second parameter to React.useEffect(). This will cause the effect to run just once, on startup. The example below is the Leaflet map example re-written to do this:

import React from 'react';
import 'leaflet';

function LeafletMap2({lat1, lon1}) {
    const map = React.useRef(null);
    const [lat, setLat] = React.useState(lat1 || 51.05);
    const [lon, setLon] = React.useState(lon1 || -0.72);

    React.useEffect( ()=> {
        // As effect will run only once, there is no need to check if map.current is null
        map.current = L.map("map1");

        // Set the map up in the normal way
        L.tileLayer
        ("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
            { attribution: "Copyright OSM contributors, ODBL" } ).addTo(map.current);
        const pos = [lat, lon];    
        map.current.setView(pos, 14);

        // Handle the map moveend event by updating the state appropriately
        map.current.on("moveend", e=> {
            const centre = map.current.getCenter();
            setLat(centre.lat);
            setLon(centre.lng);
        });
    },[]);


    return(
        <div>
        Lat: <input id='lat' />
        Lon: <input id='lon' />
        <input type='button' value='go' onClick={setPos} />
        <p>Map centred at: {lat} {lon}</p>
        <div id="map1" style={{width:"800px", height:"600px"}}></div>
        </div>
    );
    
    function setPos() {
        const lt = document.getElementById('lat').value;
        const lng = document.getElementById('lon').value;
        setLat(lt);
        setLon(lng);
        map.current.setView([lt, lng], 14);
    }
}

export default LeafletMap2;

What is this empty array? It's an array of dependencies: a series of props or state variables which will trigger a re-run of the effect if they change. You can fill this array with such props or state variables if you want to re-run the effect if any props or state variables change. If the array is empty, however, you are specifying that the effect should never be re-run: it should only run once when the component first loads.

Exercise

Advanced exercise

Advanced: If you get that done, develop the map application so you have a SearchResults component (within the parent component) which should list all results (not just the first). Store the results in state. Also show all results as markers on the map. Each result should have a button labelled "Go to this location" which, when clicked, should set the latitude and longitude of the map to that location.

Here is an example of some JSX (which you would generate from map()) showing how you could pass parameters into an onClick handler. You could set the onClick handler to be an arrow function, and then inside the arrow function, call another function (goTo() here) which moves the map to the specified location.

<input type='button' onClick={() => { goTo(result.lat,result.lon) } } />