4
votes

Is there a non hacky way to keep Material UI tabs and React router in sync?

Basically, I want to change the URL when the user clicks on a tab [1] and the tabs should change automatically when the user navigates to a different page with a non-tab link or button, and of course on direct access [2] and page refresh too.

Also, it would be nice to have the react router's non exact feature too, so the /foo tab should be active both for /foo and /foo/bar/1.

[1] Other SO answers recommend using the history api directly, is that a good practice with react-router?

[2] I'm not sure what it's called, I meant when the user loads for example /foo directly instead of loading / and then navigating to /foo by a tab or link


Edit:

I created a wrapper component which does the job, but with a few problems:

class CustomTabs extends React.PureComponent {
    constructor() {
        super();

        this.state = {
            activeTab: 0
        }
    }

    setActiveTab(id) {
        this.setState({
            activeTab: id
        });
        return null;
    }

    render() {
        return (
            <div>
                {this.props.children.map((tab,index) => {
                    return (
                        <Route
                            key={index}
                            path={tab.props.path||"/"}
                            exact={tab.props.exact||false}
                            render={() => this.setActiveTab(index)}
                        />
                    );
                })}
                <Tabs
                    style={{height: '64px'}}
                    contentContainerStyle={{height: '100%'}}
                    tabItemContainerStyle={{height: '100%'}}
                    value={this.state.activeTab}
                >
                    {this.props.children.map((tab,index) => {
                        return (
                            <Tab
                                key={index}
                                value={index}
                                label={tab.props.label||""}
                                style={{paddingLeft: '10px', paddingRight: '10px', height: '64px'}}
                                onActive={() => {
                                    this.props.history.push(tab.props.path||"/")
                                }}
                            />
                        );
                    })}
                </Tabs>
            </div>
        );
    }
}

And I'm using it like this:

<AppBar title="Title" showMenuIconButton={false}>
    <CustomTabs history={this.props.history}>
        <Tab label="Home" path="/" exact/>
        <Tab label="Foo" path="/foo"/>
        <Tab label="Bar" path="/bar"/>
    </CustomTabs>
</AppBar>

But:

  • I get this warning in my console:

Warning: setState(...): Cannot update during an existing state transition (such as within render or another component's constructor). Render methods should be a pure function of props and state; constructor side-effects are an anti-pattern, but can be moved to componentWillMount.

I think it's because I set the state immediately after render() is called - because of Route.render, but I have no idea how to solve this.


Edit #2

I finally solved everything, but in a bit hacky way.

class CustomTabsImpl extends PureComponent {
    constructor() {
        super();

        this.state = {
            activeTab: 0
        }
    }

    componentWillMount() {
        this.state.activeTab = this.pathToTab(); // eslint-disable-line react/no-direct-mutation-state
    }

    componentWillUpdate() {
        setTimeout(() => {
            let newTab = this.pathToTab();
            this.setState({
                activeTab: newTab
            });
        }, 1);
    }

    pathToTab() {
        let newTab = 0;

        this.props.children.forEach((tab,index) => {
            let match = matchPath(this.props.location.pathname, {
                path: tab.props.path || "/",
                exact: tab.props.exact || false
            });
            if(match) {
                newTab = index;
            }
        });

        return newTab;
    }

    changeHandler(id, event, tab) {
        this.props.history.push(tab.props['data-path'] || "/");
        this.setState({
            activeTab: id
        });
    }

    render() {
        return (
            <div>
                <Tabs
                    style={{height: '64px'}}
                    contentContainerStyle={{height: '100%'}}
                    tabItemContainerStyle={{height: '100%'}}
                    onChange={(id,event,tab) => this.changeHandler(id,event,tab)}
                    value={this.state.activeTab}
                >
                    {this.props.children.map((tab,index) => {
                        return (
                            <Tab
                                key={index}
                                value={index}
                                label={tab.props.label||""}
                                data-path={tab.props.path||"/"}
                                style={{height: '64px', width: '100px'}}
                            />
                        );
                    })}
                </Tabs>
            </div>
        );
    }
}

const CustomTabs = withRouter(CustomTabsImpl);
2
Can you post small snippets of your code to understand your problem more clearly ?pizzarob

2 Answers

1
votes

Firstly, thanks for replying to your very question. I have approached this question differently, I decided to post here for the community appreciation.

My reasoning here was: "It would be simpler if I could tell the Tab instead the Tabs component about which one is active."

Accomplishing that is quite trivial, one can do that by setting a known fixed value to the Tabs component and assign that very value to whatever tab is supposed to be active.

This solution requires that the component hosting the tabs has access to the props such as location and match from react-router as follows


Firstly, we create a function that factory that removes bloated code from the render method. Here were are setting the fixed Tabs value to the Tab if the desired route matches, other wise I'm just throwing an arbitrary constant such as Infinity.

const mountTabValueFactory = (location, tabId) => (route) => !!matchPath(location.pathname, { path: route, exact: true }) ? tabId : Infinity;

After that, all you need is to plug the info to your render function.

render() {
   const {location, match} = this.props;
   const tabId = 'myTabId';
   const getTabValue = mountTabValueFactory(location, tabId);

   return (
     <Tabs value={tabId}>
       <Tab
         value={getTabValue('/route/:id')}
         label="tab1"
         onClick={() => history.push(`${match.url}`)}/>
       <Tab
         value={getTabValue('/route/:id/sub-route')}
         label="tab2"
         onClick={() => history.push(`${match.url}/sub-route`)}
       />
     </Tabs>
   )
}
-2
votes

You can use react routers NavLink component

import { NavLink } from 'react-router-dom';

<NavLink
  activeClassName="active"
  to="/foo"
>Tab 1</NavLink>

When /foo is the route then the active class will be added to this link. NavLink also has an isActive prop that can be passed a function to further customize the functionality which determines whether or not the link is active.

https://reacttraining.com/react-router/web/api/NavLink