0

We have a variable in our React application that is both:

  1. Defined as global state in App.js, passed globally with its setter method to other components via the GlobalContext.Provider, and
  2. Used separately as a route parameter for many of the app's routes.

Below is a short code snippet of the relevant section of our App.js file then:

import React, { useState, useEffect } from 'react';
import GlobalContext from './context/GlobalContext';
import OtherComponents...

function App() {
    const [competition, setCompetition] = useState({ value: 15, label: 'Season 1' });

    return (
        <GlobalContext.Provider value={{ // Pass Global State Through To Entire App
            competition, setCompetition
        }}>
            <Navbar />
            <Switch>
                <Route exact path='/' render={(props) => <HomePage {...props} />} />
                <Route exact path='/stats' component={StatsPage} />
                <Route exact path='/about' component={AboutUs} />
                <Route exact path='/persons/:competitionId/ component={PersonsComponent} />
                <Route exact path='/teams/:competitionId component={TeamsComponent} />
            </Switch>
        </GlobalContext.Provider>
    );
}

export default App;

The competition global state has keys value and label, and the competitionId in the url parameter is then the same valu as the competition.value value.

The global state value for competition is meant to be changed in the <Navbar> component using a select widget. When this widget is toggled, the global state is updated, and the useHistory hook is used to push to app to the new route, using the updated competition.value to set the competitionId url parameter.

The value for competition is needed in many components in our application, including those components where there are no url parameters (e.g. in the <HomePage> component). For this reason, we feel that it is needed as a global variable, passed down to all other components. This is also very convenient for us as the variable is easily accessibly anywhere with the useContext hook.

However, the value also seems needed in our url parameters. These components fetch different data based on the competitionId passed, and them being in the url parameters is a big part of the routing of the app.

Our Problem is then that users can manually change the website's url, which can change the url parameters without also changing the global state of the variable. By changing the url manually, rather than with the select widget, the global state and url parameters then go out of sync...

Edit: Here is the select component that we use to toggle the competition value (sorry the post is getting long). This select is up in our Navbar, and is globally acessable as it's outside our <Switch>:

function CompetitionSelect({ currentPath }) {
    // Grab History In Order To Push To Selected Pages
    let history = useHistory();
    let { competition, setCompetition } = useContext(GlobalContext);

    // Fetch Data on All Competitions (dropdown options)
    const competitionInfosConfig = {};
    const [competitionInfos, isLoading1, isError1] = useInternalApi('competitionInfo', [], competitionInfosConfig);

    // Messy digging of competitionId out of current path.
    let competitionIds = competitionInfos.map(row => row.competitionId);
    let pathCompetitionId = null;
    competitionIds.forEach(id => {
        if (currentPath.includes(`/${id}/`)) {
            pathCompetitionId = id;
        }
    });

    // Messy Handling State/Params Out Of Sync
    if (pathCompetitionId === null) {
        console.log('Not a page where testing is needed');
    }
    else if (competition.value !== pathCompetitionId) {
        console.log('WERE OUT OF SYNC...');
        let correctGlobalState = { value: pathCompetitionId, label: 'Label Set' };
        setCompetition(correctGlobalState);
    } else {
        console.log('IN SYNC: ', competition.value, ' == ', pathCompetitionId);
    }

    // Handle updating state + pushing to new route
    const handleSelect = (event) => {
        let oldPath = JSON.parse(JSON.stringify(history.location.pathname));
        let newPath = '';
        competitionIds.forEach(id => {
            if (oldPath.includes(`/${id}/`)) {
                newPath = oldPath.replace(`/${id}/`, `/${event.value}/`)
            }
        });

        if (newPath !== '') {
            setCompetition(event);
            history.push(newPath);
        }
    };

    // Create The Select
    const competitionSelect =
        (<Select
            styles={appSelectStyles}
            value={competition}
            options={competitionInfos}
            onChange={handleSelect}
            placeholder={'Select Competition'}
        />);

    return (
        {competitionSelect}
    );
}

export default CompetitionSelect;

This component technically does resolve the out-of-sync issue in the if, if else, else clause, however whenever setCompetition(correctGlobalState) is called, React throws the following warning message:

Warning: Cannot update a component (App) while rendering a different component (CompetitionSelect). To locate the bad setState() call inside CompetitionSelect, follow the stack trace as described...

Canovice
  • 9,012
  • 22
  • 93
  • 211
  • 1
    Shouldn't you treat url as the state? – ksav Jul 31 '20 at 00:53
  • Maybe? I'm not sure what is best, although it does seem like the url params and the global state are duplicative... – Canovice Jul 31 '20 at 00:58
  • 1
    I would try to avoid duplication of state and make data only flow in one direction. When the user changes the dropdown value, update the url, and re-render the component. – ksav Jul 31 '20 at 01:01
  • One positive about the global state is that is globally accessible from all components, whereas I can only get route parameters from the top-level `route` components (not the nested components). When `useParams()` is run from within a nested component not defined in the `App.js` ``, it returns an empty object... – Canovice Jul 31 '20 at 01:02
  • It will be much easier having one source of truth (the url param) and passing it down wherever you need it. I don't understand where you would need it without having it available on the `useParams` response. Would you show an example where you can't use `useParams`? – Arkanoid Jul 31 '20 at 01:45
  • See the 4th edit. Pretty much, we need access to this global `competition` value even on pages without the `competition` url parameter – Canovice Jul 31 '20 at 18:39

1 Answers1

1

I think a good way to deal with this is

  • attempt to get competition data out of the URL
  • use that as the initial value for the state if available, or a default if not
  • use a useEffect to update the state with this competition data from the URL if it's available, on location change

For example

function App() {
  const location = useLocation();
  const params = useParams();

  // implement getCompetitionFromParams as appropriate
  // returns undefined if no competition data in URL params
  const competitionFromParams = getCompetitionFromParams(location, params)

  const [competition, setCompetition] = 
    // init state on first load, 
    // with default if competitionFromParams not available
    useState(competitionFromParams || { value: 15, label: 'Season 1' });


  // update state on location change,
  // if competitionFromParams available
  useEffect(() => {
    if (competitionFromParams !== undefined) {
      setCompetition(competitionFromParams); 
    }
  }, [location]); // runs useEffect only on location change

  ...

With that, you get what you want - competition in global state, the ability to set it manually with a select, but also have it auto-synced when it appears in the relevant URL parameters.

davnicwil
  • 28,487
  • 16
  • 107
  • 123
  • This looks great. However, by having this in the `App.js` file, wouldn't this introduce an additional re-render of the page every single time the location changes (assuming `competitionFromParams !== undefined` and we run `setCompetition`) – Canovice Aug 04 '20 at 05:36
  • 1
    Yes, that's true. This isn't optimised, it's more just to demonstrate the general mechanism, but for example you could optimise this more by adding the relevant competition data values from `params` to the `useEffect` dependencies, so state is updated not only when location changes but when the actual competition data changes too. Whether or not that kind of optimisation is necessary I guess depends on your app & performance requirements. – davnicwil Aug 04 '20 at 08:07