React Router v4 Nested match params not accessible at root level

13,446

Solution 1

Try utilizing query parameters ? to allow the parent and child to access the current selected topic. Unfortunately, you will need to use the module qs because react-router-dom doesn't automatically parse queries (react-router v3 does).

Working example: https://codesandbox.io/s/my1ljx40r9

URL is structured like a concatenated string:

topic?topic=props-v-state

Then you would add to the query with &:

/topics/topic?topic=optimization&category=pure-components&subcategory=shouldComponentUpdate

✔ Uses match for Route URL handling

✔ Doesn't use this.props.location.pathname (uses this.props.location.search)

✔ Uses qs to parse location.search

✔ Does not involve hacky approaches

Topics.js

import React from "react";
import { Link, Route } from "react-router-dom";
import qs from "qs";
import Topic from "./Topic";

export default ({ match, location }) => {
  const { topic } = qs.parse(location.search, {
    ignoreQueryPrefix: true
  });

  return (
    <div>
      <h2>Topics</h2>
      <ul>
        <li>
          <Link to={`${match.url}/topic?topic=rendering`}>
            Rendering with React
          </Link>
        </li>
        <li>
          <Link to={`${match.url}/topic?topic=components`}>Components</Link>
        </li>
        <li>
          <Link to={`${match.url}/topic?topic=props-v-state`}>
            Props v. State
          </Link>
        </li>
      </ul>
      <h2>
        Topic ID param from Topic<strong>s</strong> Components
      </h2>
      <h3>{topic && topic}</h3>
      <Route
        path={`${match.url}/:topicId`}
        render={props => <Topic {...props} topic={topic} />}
      />
      <Route
        exact
        path={match.url}
        render={() => <h3>Please select a topic.</h3>}
      />
    </div>
  );
};

Another approach would be to create a HOC that stores params to state and children update the parent's state when its params have changed.

URL is structured like a folder tree: /topics/rendering/optimization/pure-components/shouldComponentUpdate

Working example: https://codesandbox.io/s/9joknpm9jy

✔ Uses match for Route URL handling

✔ Doesn't use this.props.location.pathname

✔ Uses lodash for object to object comparison

✔ Does not involve hacky approaches

Topics.js

import map from "lodash/map";
import React, { Fragment, Component } from "react";
import NestedRoutes from "./NestedRoutes";
import Links from "./Links";
import createPath from "./createPath";

export default class Topics extends Component {
  state = {
    params: "",
    paths: []
  };

  componentDidMount = () => {
    const urlPaths = [
      this.props.match.url,
      ":topicId",
      ":subcategory",
      ":item",
      ":lifecycles"
    ];
    this.setState({ paths: createPath(urlPaths) });
  };

  handleUrlChange = params => this.setState({ params });

  showParams = params =>
    !params
      ? null
      : map(params, name => <Fragment key={name}>{name} </Fragment>);

  render = () => (
    <div>
      <h2>Topics</h2>
      <Links match={this.props.match} />
      <h2>
        Topic ID param from Topic<strong>s</strong> Components
      </h2>
      <h3>{this.state.params && this.showParams(this.state.params)}</h3>
      <NestedRoutes
        handleUrlChange={this.handleUrlChange}
        match={this.props.match}
        paths={this.state.paths}
        showParams={this.showParams}
      />
    </div>
  );
}

NestedRoutes.js

import map from "lodash/map";
import React, { Fragment } from "react";
import { Route } from "react-router-dom";
import Topic from "./Topic";

export default ({ handleUrlChange, match, paths, showParams }) => (
  <Fragment>
    {map(paths, path => (
      <Route
        exact
        key={path}
        path={path}
        render={props => (
          <Topic
            {...props}
            handleUrlChange={handleUrlChange}
            showParams={showParams}
          />
        )}
      />
    ))}
    <Route
      exact
      path={match.url}
      render={() => <h3>Please select a topic.</h3>}
    />
  </Fragment>
);

Solution 2

React-router doesn't give you the match params of any of the matched children Route , rather it gives you the params based on the current match. So if you have your Routes setup like

<Route path='/topic' component={Topics} />

and in Topics component you have a Route like

<Route path=`${match.url}/:topicId` component={Topic} />

Now if your url is /topic/topic1 which matched the inner Route but for the Topics component, the matched Route is still, /topic and hence has no params in it, which makes sense.

If you want to fetch params of the children Route matched in the topics component, you would need to make use of matchPath utility provided by React-router and test against the child route whose params you want to obtain

import { matchPath } from 'react-router'

render(){
    const {users, flags, location } = this.props;
    const match = matchPath(location.pathname, {
       path: '/topic/:topicId',
       exact: true,
       strict: false
    })
    if(match) {
        console.log(match.params.topicId);
    }
    return (
        <div>
            <Route exact path="/topic/:topicId" component={Topic} />
        </div>
    )
}

EDIT:

One method to get all the params at any level is to make use of context and update the params as and when they match in the context Provider.

You would need to create a wrapper around Route for it to work correctly, A typical example would look like

RouteWrapper.jsx

import React from "react";
import _ from "lodash";
import { matchPath } from "react-router-dom";
import { ParamContext } from "./ParamsContext";
import { withRouter, Route } from "react-router-dom";

class CustomRoute extends React.Component {
  getMatchParams = props => {
    const { location, path, exact, strict } = props || this.props;
    const match = matchPath(location.pathname, {
      path,
      exact,
      strict
    });
    if (match) {
      console.log(match.params);
      return match.params;
    }
    return {};
  };
  componentDidMount() {
    const { updateParams } = this.props;
    updateParams(this.getMatchParams());
  }
  componentDidUpdate(prevProps) {
    const { updateParams, match } = this.props;
    const currentParams = this.getMatchParams();
    const prevParams = this.getMatchParams(prevProps);
    if (!_.isEqual(currentParams, prevParams)) {
      updateParams(match.params);
    }
  }

  componentWillUnmount() {
    const { updateParams } = this.props;
    const matchParams = this.getMatchParams();
    Object.keys(matchParams).forEach(k => (matchParams[k] = undefined));
    updateParams(matchParams);
  }
  render() {
    return <Route {...this.props} />;
  }
}

const RouteWithRouter = withRouter(CustomRoute);

export default props => (
  <ParamContext.Consumer>
    {({ updateParams }) => {
      return <RouteWithRouter updateParams={updateParams} {...props} />;
    }}
  </ParamContext.Consumer>
);

ParamsProvider.jsx

import React from "react";
import { ParamContext } from "./ParamsContext";
export default class ParamsProvider extends React.Component {
  state = {
    allParams: {}
  };
  updateParams = params => {
    console.log({ params: JSON.stringify(params) });
    this.setState(prevProps => ({
      allParams: {
        ...prevProps.allParams,
        ...params
      }
    }));
  };
  render() {
    return (
      <ParamContext.Provider
        value={{
          allParams: this.state.allParams,
          updateParams: this.updateParams
        }}
      >
        {this.props.children}
      </ParamContext.Provider>
    );
  }
}

Index.js

ReactDOM.render(
  <BrowserRouter>
    <ParamsProvider>
      <App />
    </ParamsProvider>
  </BrowserRouter>,
  document.getElementById("root")
);

Working DEMO

Solution 3

If you have a known set of child routes then you can use something like this:

Import {BrowserRouter as Router, Route, Switch } from 'react-router-dom'

 <Router>
  <Route path={`${baseUrl}/home/:expectedTag?/:expectedEvent?`} component={Parent} />
</Router>
const Parent = (props) => {
    return (
       <div >
         <Switch>
         <Route path={`${baseUrl}/home/summary`} component={ChildOne} />
         <Route
           path={`${baseUrl}/home/:activeTag/:activeEvent?/:activeIndex?`}
           component={ChildTwo}
          />
         </Switch> 
       <div>
      )
    }

In the above example Parent will get expectedTag, expectedEvent as the match params and there is no conflict with the child components and Child component will get activeTag, activeEvent, activeIndex as the parameters. Same name for params can also be used, I have tried that as well.

Share:
13,446

Related videos on Youtube

nikjohn
Author by

nikjohn

Updated on September 15, 2022

Comments

  • nikjohn
    nikjohn over 1 year

    Test Case

    https://codesandbox.io/s/rr00y9w2wm

    Steps to reproduce

    OR

    Expected Behavior

    • match.params.topicId should be identical from both the parent Topics component should be the same as match.params.topicId when accessed within the Topic component

    Actual Behavior

    • match.params.topicId when accessed within the Topic component is undefined
    • match.params.topicId when accessed within the Topics component is rendering

    I understand from this closed issue that this is not necessarily a bug.

    This requirement is super common among users who want to create a run in the mill web application where a component Topics at a parent level needs to access the match.params.paramId where paramId is a URL param that matches a nested (child) component Topic:

    const Topic = ({ match }) => (
      <div>
        <h2>Topic ID param from Topic Components</h2>
        <h3>{match.params.topicId}</h3>
      </div>
    );
    
    const Topics = ({ match }) => (
      <div>
        <h2>Topics</h2>
        <h3>{match.params.topicId || "undefined"}</h3>
        <Route path={`${match.url}/:topicId`} component={Topic} />
        ...
      </div>
    );
    

    In a generic sense, Topics could be a Drawer or Navigation Menu component and Topic could be any child component, like it is in the application I'm developing. The child component has it's own :topicId param which has it's own (let's say) <Route path="sections/:sectionId" component={Section} /> Route/Component.

    Even more painful, the Navigation Menu needn't have a one-to-one relationship with the component tree. Sometimes the items at the root level of the menu (say Topics, Sections etc.) might correspond to a nested structure (Sections is only rendered under a Topic, /topics/:topicId/sections/:sectionId though it has its own normalized list that is available to the user under the title Sections in the Navigation Bar). Therefore, when Sections is clicked, it should be highlighted, and not both Sections and Topics.

    With the sectionId or sections path unavailable to the Navigation Bar component which is at the Root level of the application, it becomes necessary to write hacks like this for such a commonplace use case.

    I am not an expert at all at React Router, so if anyone can venture a proper elegant solution to this use case, I would consider this to be a fruitful endeavor. And by elegant, I mean

    • Uses match and not history.location.pathname
    • Does not involve hacky approaches like manually parsing the window.location.xxx
    • Doesn't use this.props.location.pathname
    • Does not use third party libraries like path-to-regexp
    • Does not use query params

    Other hacks/partial solutions/related questions:

    1. React Router v4 - How to get current route?

    2. React Router v4 global no match to nested route childs

    TIA!

  • nikjohn
    nikjohn over 5 years
    I considered this approach but it seemed hacky because we should be able to use URL path params here. This is a simple straight forward use case, it's almost a standard use case
  • nikjohn
    nikjohn over 5 years
    It doesn't seem like we should have to use query params here IMO. That being said, I'll add that to the cases in the question
  • Matt Carlotta
    Matt Carlotta over 5 years
    To me, something like this seems too general to match against: path: "/orgs/:orgId/projects/:projectId/version/:version/models/:m‌​odelId". Why have 4 unknown params, when in practice, you're essentially handling this URL (on the backend): /orgs?orgsId={orgsId}&projects={projectsId}&version={version‌​Id}&models=${modelsI‌​d}. Also, at what point would you want/need to limit the URL params (especially if you're doing breadcrumbs): /company/companyname/invoices/browse/index/orderby/amount/so‌​rtby/date/desc/limit‌​/50. Seems like a nightmare to handle all cases just to match to a single component.
  • Matt Carlotta
    Matt Carlotta over 5 years
    Makes more sense to match to a root URL, then handle the queries separately. For example, you match to: /company/:params, then you can add 1, 10, 20... as many queries as you want without having to handle them in your Route's path. Just as easily, you can remove the queries without having to navigate to another component.
  • Matt Carlotta
    Matt Carlotta over 5 years
    Other than a website serving a static folder-like structure (like Github), is there any other advantage or purpose to the routing you're trying to achieve?
  • Matt Carlotta
    Matt Carlotta over 5 years
    Updated answer to include a folder tree example.
  • nikjohn
    nikjohn over 5 years
    Thanks for your answer, Shubham. But this doesn't seem to be very DRY friendly, considering I'll need to create every path twice, once on the Route and then on the Navigation Menu. I'm looking for a DRY solution
  • Matt Carlotta
    Matt Carlotta over 5 years
    Updated second option (and codesandbox) to be DRYer.
  • Shubham Khatri
    Shubham Khatri over 5 years
    @nikjohn A DRY friendly solution would be to make use of Context, I will update my answer to add a Context based solution in some time
  • Shubham Khatri
    Shubham Khatri over 5 years
    @nikjohn, Added a demo for the DRY friendly method using context, Please have a look at it. It might have some bugs, but I assume it would be helpful for you to develop on top of that
  • nikjohn
    nikjohn over 5 years
    I ended up doing something similar to this, so I'm awarding you the bounty. For the record, I still think the React Router 4 approach is not suited for some common use cases and it frustrates me that it is so. I hope someone comes up with an alternate approach like React Little Router(formidable.com/blog/2016/07/25/…)
  • nikjohn
    nikjohn over 5 years
    I used some parts of your answer in my code, so marking this as the correct answer. Thank you for your efforts
  • Shubham Khatri
    Shubham Khatri over 5 years
    @nikjohn, I agree that react-router-v4 isn't best suited for all use cases and we have to perform a lot of tweaks to get the result. The above solution is one amongst it
  • xtrinch
    xtrinch almost 4 years
    That's certainly the easiest way, an example of a route that works: TEST_PARENT_ROUTE = '/parent/:parentId/children1/:childId1/:children2?/:childId2‌​?/:children3?/:child‌​Id3?'
  • tarun jain
    tarun jain almost 4 years
    Thanks @xtrinch, this was my first answer, any comments or help on how to improve in community support.