Problem
I'm trying to make an SPA with routing (ideally with React hooks) in React, but all the examples, descriptions i find are about displaying different components based on the URL. What i want is something like Youtube or Google docs, where the page structure/components are (mostly) the same and only the content changes.
Context
(Edit: adding a bit more context.)
This is going to be a document editor/presenter.
Page structure: after login, there is always a toolbar(blue color) on the top, for menus, notifications, etc. The rest of the screen will be mostly like the two examples below:
Example1:
Example2:
The search pane(orange) could be switched on/off by a button on the toolbar or by a user session variable. The document will be presented in the document section(grey) based on either a user session variable, doc ID provided in URL or selecting a document in the search pane.
Planned URLs
(Added in edit.)
Landing page: /login , login page.
Landing page: / , here the toolbar and a preconfigured, user session based default doc would be presented.
Document page: /doc?id=oys2OPkfOwQ , same as landing page but document section would contain the document with ID provided as query param.
Anything else: /something , toolbar and something under it.
Idea
(Added in edit.)
The layout is defined by CSS grid and page structure changes based on a variable. So this is going to be a prop for the App component coming from default value and user session configured variable and could change later.
This is the functionality i imagine for the App component (pseudo code-like thing):
<Router>
<Route path='/login'>
<Login/>
// Components: Toolbar and something under it
</Route>
<Route path='/'>
<DocApp/>
// Components: Toolbar, Document or Toolbar, Search and Document
// Default document loaded for default, not logged in user
// Default document loaded from stored user session
</Route>
<Route path='/doc'>
<DocApp/>
// Components: Toolbar, Document or Toolbar, Search and Document
// Same as for '/' except document with ID set as query param is displayed
// This could be called/triggered from search and document component as well
</Route>
<Route path='/somethingelse'>
<SomethingElse/>
</Route>
</Router>
Question
(Edit: rephrased, original question was how to implement a solution where different documents loaded based on URL query parameter.)
What i'm mostly interested in if there is a simpler way to draw the landing layout '/' and specific doc presenter /doc?id=oys2OPkfOwQ layout? In both cases the same components get displayed, only the provided parameter(doc to present) is different.
Solution
(Added in edit.)
By reading the answers and feedback and re-thinking my problem i realized that i have a multiple URLs same content problem.
Using React Router to render components based on UrlParams.
First of all, edit your routes to render DocumentLoader component under the route /doc
// file: app.js
import React from "react";
import { BrowserRouter, Route } from "react-router-dom";
import DocumentLoader from "./DocumentLoader";
const App = (props) => {
return <BrowserRouter>
<Routes>
<Route path="/doc" element={<DocumentLoader />}>
</Routes>
</BrowserRouter>
}
Create custom hooks for loading documents
You need two custom hooks, one for loading new document by changing the docId query parameter, and another hook to listen to docId changes to reload new document from your backend.
NOTE: Edit loadDocumentData to load from your backend
// file: hooks.js
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
/**
* TODO:// Refactor this function to call your backend to get
* Document data by docId
*/
const loadDocumentData = (docId) =>
new Promise((resolve, reject) => {
// this setTimeout for demonstration porpuse only
setTimeout(() => {
resolve({ id: docId, name: `Document name for ${docId}` });
}, 3000);
});
export const useDocument = () => {
const [loading, setLoading] = useState(true);
const { docId, loadDocument } = useDocumentParam();
const [document, setDocument] = useState(null);
useEffect(() => {
setLoading(true);
// Load your document data based on docID
loadDocumentData(docId)
.then((doc) => {
setDocument(doc);
setLoading(false);
})
.catch((e) => {
console.error('Failed to load doc', docId);
});
}, [docId, setLoading]);
return { document, loading, loadDocument };
};
export const useDocumentParam = () => {
const [searchParams, setSearchParams] = useSearchParams();
const docId = searchParams.get('d');
const loadDocument = useCallback(
(newDocId) => {
setSearchParams({ d: newDocId });
},
[setSearchParams]
);
return { docId, loadDocument };
};
Create DocumentLoader component
To listen on query param changes, load document from server-side, display loading indicator and render the "DocPresenter" component.
// file: DocumentLoader.js
import * as React from 'react';
import DocPresenter from './DocPresenter';
import { useDocument } from './hooks';
const DocumentLoader = (props) => {
const { loading, document, loadDocument } = useDocument();
if (loading) {
return <div>Display loading indicator while loading the document</div>;
}
return (
<div className="document-container">
<div className="toolbar">NavBar</div>
<div className="searchbox">search component</div>
<div className="editor">
<DocPresenter document={document} setParentstate={loadDocument} />
</div>
</div>
);
};
export default DocumentLoader;
Checkout Live Example on StackBlitz.
Helper Links:
React Router Docs
React Custom Hooks Docs
Here's how I would do it. Notice that the URL will remain the same.
const DynamicComponent = () => {
const components = {
Component1: <Component1 />,
Component2: <Component2 />,
Component3: <Component3 />,
};
const [component, setComponent] = useState(components["Component1"]);
return (
<div>
<div id="nav">
<span onClick={(e) => setComponent(components["Component1"])}>
Set to component 1
</span>
<span onClick={(e) => setComponent(components["Component2"])}>
Set to component 2
</span>
<span onClick={(e) => setComponent(components["Component3"])}>
Set to component 3
</span>
</div>
<div>{component}</div>
</div>
);
};
export default DynamicComponent;
Related
i want to go from a component to another and slide directly to an html tag inside of it .
using react router v6
<Route path="/mypage/#c" element = {<MyPage/>} />
( #c is a id of the html tag in the /mypage )
but the code i've provided isn't working .
it just goes to the page and doesn't slide into the tag .
The hash should be added to the link or simply read from the URL, i.e. the location object, instead specified on the Route component's path prop. Adding it to the path will break route path matching.
<Route path="/mypage" element={<MyPage />} />
react-router-dom#6 doesn't handle linking directly to hash-tags in the document, and at the moment the best library out there for doing this in previous versions of RRD, react-router-hash-link has yet to update to support RRDv6.
The MyPage component could use a useEffect hook with a dependency on the location.hash, query the DOM, and attempt to scroll that element into view.
Example:
import { useLocation } from "react-router-dom";
const MyPage = () => {
const { hash } = useLocation();
useEffect(() => {
const el = document.querySelector(hash);
if (el) {
el.scrollIntoView({ behavior: "auto" });
}
}, [hash]);
return (
<>
... content ...
<div id="c">This is the content I'm interested in</div>
... more content ...
</>
);
};
In my app, I am using react-router v5 and react/typescript I have a component that uses the react-query and fetches some data. At the moment it only fetches the data when the component is rendered the first time, When navigating the request does not get cancelled and navigating back it does not make a new request. This component takes in an id parameter which fetches the data based on the id, so it needs to either refresh the component or maybe I need to add the method into the useEffect hook?
Routing component
import React from 'react';
import { BrowserRouter, Route, Switch} from 'react-router-dom';
import { QueryClient, QueryClientProvider } from 'react-query';
import { RouteComponentProps } from "react-router-dom";
import Component1 from '../Component1';
import Component2 from '../Component2';
const queryClient = new QueryClient()
const Routing: React.FunctionComponent = () => {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Switch>
<Route exact path="/" component={Component1} />
<Route path="/details/:id" render={(props: RouteComponentProps<any>) => <Component2 {...props}/>} />
<Route component={NotFound} />
</Switch>
</BrowserRouter>
</QueryClientProvider>
)
}
export default Routing;
Component2 (id)
import React from 'react';
import { useQuery } from 'react-query';
import { RouteComponentProps, useLocation } from "react-router-dom";
interface stateType {
model: { pathname: string },
start: { pathname: string | Date }
}
const Component2: React.FunctionComponent<RouteComponentProps<any>> = (props) => {
const { state } = useLocation<stateType>();
let alertInnerId = props.match.params.id;
const fetchChart = async () => {
const res = await fetch(`/detail/${id}`);
return res.json();
};
const { data, status } = useQuery('planeInfo', fetchPlane, {
staleTime: 5000,
});
return (
<>
{status === 'error' && (
<div className="mt-5">Error fetching data!</div>
)}
{status === 'loading' && (
<div className="mt-5">Loading data ...
</div>
)}
{status === 'success' && (
{data.map(inner => {
return (
<p>{inner.id}</p>
)
})}
)}
</div>
</>
)
}
export default Component2;
In the Component 1 I am programmatically navigating:
onClick={() => history.push(`/detail/${id}}`, { model: plane.model, start: formattedStartDateTime })}>
Either way by programmatically or normal, its still the same.
[...] and navigating back it does not make a new request.
First of all, according to your code, as per the staleTime option that is set as an option on useQuery itself, the cache should invalidate every five seconds. So each time the useQuery hook is mounted (such as on route change), if five seconds have passed, a new request should be made. Your code does appear to be incomplete though as you're referencing id which appears to be undefined.
In any case, since you are requesting details of a resource with an ID, you should consider using a query key like: [planeInfo, id] instead of planeInfo alone. From the documentation:
Since query keys uniquely describe the data they are fetching, they
should include any variables you use in your query function that
change. For example:
function Todos({ todoId }) {
const result = useQuery(['todos', todoId], () =>
fetchTodoById(todoId))
}
To handle canceling the request on navigation:
You can't wrap the useQuery hook from React Query in a useEffect hook, but rather you can use use the return function of useEffect to clean up your useQuery request, effectively canceling the request when the component unmounts. With useQuery there are two ways (possibly more) to cancel a request:
use the remove method exposed on the returned object of useQuery
use the QueryClient method: cancelQueries
(see: useQuery reference here)
see: QueryClient reference here and specifically cancelQueries
Using remove with useEffect
(I've only kept the relevant bits of your code)
const Component2: React.FunctionComponent <RouteComponentProps<any>> = (props) => {
const fetchChart = async() => {
const res = await fetch(`/detail/${id}`);
return res.json();
};
const {
data,
status,
/** access the remove method **/
remove
} = useQuery('planeInfo', fetchPlane, {
staleTime: 5000,
});
useEffect(() => {
/** when this component unmounts, call it **/
return () => remove()
/** make sure to set an empty deps array **/
}, [])
/** the rest of your component **/
}
Calling remove like this will cancel any ongoing request, but as its name suggests, it also removes the query from the cache. Depending on whether you need to keep the data in cache or not, this may or may not be a viable strategy. If you need to keep the data, you can instead use the canceQueries method.
Using cancelQueries with useEffect
Much like before except here you need to export your queryClient instance from the routing component file (as you have it defined there) and then you're importing that instance of QueryClient into Component2 and calling cancelQueries on the cache key from useEffect:
import { queryClient } from "./routing-component"
const Component2: React.FunctionComponent <RouteComponentProps<any>> = (props) => {
const fetchChart = async() => {
const res = await fetch(`/detail/${id}`);
return res.json();
};
const {
data,
status,
} = useQuery(['planeInfo', id], fetchPlane, {
staleTime: 5000,
});
useEffect(() => {
/** when this component unmounts, call it **/
return () => queryClient.cancelQueries(['planeInfo', id], {exact: true, fetching: true})
}, [])
/** the rest of your component **/
}
Here you see that I've implemented the query key as I suggested before, with the id as well. You can see why having a more precise reference to the cached object can be beneficial. I'm also using two query filters: exact and fetching. Setting exact to true will make sure React Query doesn't use pattern matching and cancel a broader set of queries. You can decide whether or not this is necessary for your implementation needs. Setting fetching to true will make sure React Query includes and cancels and queries that are currently fetching data.
Just note that by depending on useEffect, it is in some cases possible for it's parent component to unmount due to factors other than the user navigating away from the page (such as a modal). In such cases, you should move your useQuery up in the component tree into a component that will only unmount when a user navigates, and then pass the result of useQuery into the child component as props, to avoid premature cancellations.
Alternatively you could use Axios instead of fetch. With Axios you can cancel a request using a global cancel token, and combine executing that cancellation with React Router's useLocation (example here). You could of course also combine useLocation listening to route changes with QueryClient.cancelQueries. There are in fact, many possible approaches to your question.
I'm using Gatsby and I would like to create a one site using multilanguage, so far I've defined pages/index.js which contains this:
import React from "react"
import Layout from "../components/layout/layout"
import BGTState from "../context/bgt/bgtState"
import { Router } from "#reach/router"
import Home from "../components/pages/home"
import Collection from "../components/pages/collection"
import NotFound from "../components/pages/404"
const IndexPage = () => {
return (
<BGTState>
<Layout>
<Router>
<Home path="/" />
<Collection path="collection/:id" />
<NotFound default />
</Router>
</Layout>
</BGTState>
)
}
export default IndexPage
and I have modified gatsby-node.js as:
// Implement the Gatsby API onCreatePage. This is
// called after every page is created.
exports.onCreatePage = async ({ page, actions }) => {
const { createPage } = actions
if (page.path === "/") {
page.matchPath = "/*"
createPage(page)
}
}
each request is forwarded on index.js, but there is a problem. I'm using the plugin gatsby-plugin-intl that add to the url a dynamic prefix like: http://localhost:3001/en/
If I visit http://localhost:3001/en/, then I get the NotFound component displayed because no Route match the url. Is there a way to prefix the url and reroute everything to the correct component?
Why you are using client-only routes/wrapping everything inside the <Router>?
I don't know what's the goal in your scenario to change the gatsby-node.js with:
// Implement the Gatsby API onCreatePage. This is
// called after every page is created.
exports.onCreatePage = async ({ page, actions }) => {
const { createPage } = actions
if (page.path === "/") {
page.matchPath = "/*"
createPage(page)
}
}
If you are not using client-only routes, you can remove them.
It's a broad question but, just define your languages and translation files. In your gatsby-config.js:
plugins: [
{
resolve: `gatsby-plugin-intl`,
options: {
// language JSON resource path
path: `${__dirname}/src/intl`,
// supported language
languages: [`en`,`es`],
// language file path
defaultLanguage: `en`,
// option to redirect to `/en` when connecting `/`
redirect: true,
},
},
]
The useIntl hook will capture the internal requests so, you just need to worry about the views, forgetting the routing:
import React from "react"
import { useIntl, Link, FormattedMessage } from "gatsby-plugin-intl"
const IndexPage = () => {
const intl = useIntl()
return (
<Layout>
<SEO title={intl.formatMessage({ id: "title" })}/>
<Link to="/page-2/">
<FormattedMessage id="go_page2" />
</Link>
</Layout>
)
}
export default IndexPage
Your Collection component should be a page, wrapped inside /page folder, or a custom collection with a specific id. If that page is dynamically created, you should manage the customizations in your gatsby-node.js, and, in that case, it should be a template of collections in that scenario.
To link between pages, I would recommend using page-queries to get the needed data to create your components. Your page should look like:
const IndexPage = () => {
return (
<BGTState>
<Layout>
<Link to="/"> // home path
<Link to="collection/1">
</Layout>
</BGTState>
)
}
export default IndexPage
The 404-page will automatically be handled by Gatsby, redirecting all wrong requests (in development will show a list of pages). Your other routing should be managed using the built-in <Link> component (extended from #reach/router from React).
To make dynamic the <Link to="collection/1"> link, you should make a page query, as I said, to get the proper link to build a custom dynamic <Link> from your data.
Once you installed the gatsby-plugin-intl plugin, all your pages will be prefixed automatically, however, to point to them using <Link> or navigate you need to get the current language and prefix it:
export const YourComponent = props => {
const { locale } = useIntl(); // here you are getting the current language
return <Link to={`${locale}/your/path`}>Your Link</Link>;
};
Because useIntl() is a custom hook provided by the plugin, the value of locale will be automatically set as you change the language.
I've made a quick CodeSandBox example what I am after. I have a "Our Courses" section on the landing page with the button "Read more". Once the "Read more" button gets clicked, depending on the Course it would render that information. Now I got the button to work but now I am stuck and can't figure out how to pass relevant information to the redirected page. Now let's say I want to get the Course "Title" and "Description" get passed onto to the redirected page. How can I do that?
CodeSandBox link here - Link here
Your CardInfo component can look-up the course detail from your courses repository.
To perform the look-up you can determine which card was selected by using the react-router useParams hook; this allows you to determine which course identifier was passed via the selected route i.e.
import React from "react";
import courses from "./courses";
import { useParams } from "react-router-dom";
const CardInfo = () => {
const { id } = useParams();
const course = courses.find(course => course.id === id);
return (
<div>
<h1>{course.title}</h1>
<p>{course.description}</p>
</div>
);
};
export default CardInfo;
A complete working example of this can be seen here (its a fork of your CodeSandBox).
You can pass data between Routes by using the object version of the to prop of Link component, so change your Link component to this:
//Card.jsx
<Link
to={{
pathname: `/card/${course.title}`,
state: {
description: course.description
}
}}>
<button className="btn">Read more</button>
</Link>
Then in your CardInfo.jsx component you can access this data by props.location.state.description
import React from "react";
const CardInfo = (props) => {
console.log(props)
return (
<div>
<h1>
How can I pass course title here depending on which button I click
</h1>
<p>{props.location.state.description}</p>
</div>
);
};
export default CardInfo;
Hope it helps :)
In your CardInfo component you can access the id provided by the route using the useParams from your react-router-dom library.
I'm using your <Route path="/card/:id" component={CardInfo} /> for reference.
Implement it like this:
import React from 'react'
import { useParams } from 'react-router-dom'
const CardInfo = () => {
const { id } = useParams()
return <div>Card ID: {id}</div>
}
export default CardInfo
Now that you've got the id you should be able to use it for whatever you need.
There are multiple approaches to pass this data:
You can pass data through the link state like this:
<Link
to={{
pathname: `/card/${course.title}`,
state: { description: course.description }
}}
>...</Link>
And then read it in the CardInfo component like this:
import { useLocation } from "react-router-dom";
const CardInfo = () => {
const location = useLocation();
console.log(location.state) // { description: 'Lorem ipsum...' }
However, the best way to do this is to pass the course id in the URL and read the rest of the information from the courses.js file:
This is already correct, you accept the course id as URL paramter:
<Route path="/card/:id" component={CardInfo} />
Pass the course id in the link:
<Link to={`/card/${course.id}`}>
Read the id parameter from the URL and get the rest of the course information from the courses file:
import { useParams } from "react-router-dom";
import courses from './courses'
const CardInfo = () => {
const params = useParams();
console.log(courses[params.id]);
This is a tricky problem.
A bit of info: I can have multiple collections of multiple images and a specific header logo for each collection. My collection url looks like this /collection/{collectionId}/item/{itemId}
const MyApp = () => {
// unrelated code
return(
<BrowserRouter>
<SiteLayout>
</BrowserRouter>
)
}
and
const SiteLayout = () => {
location = useLocation()
collectionId = location.pathname.split('/')[2] //gives me the collection Alias
const [collData, setCollData] = useState()
useEffect(() => {
const getCollectionData = async() => {
//fetch collection data
//setCollData(fetched data)
}
getCollectionData()
}, [collectionId])
return(
<CollectionContext.Provider value={collData}>
<div className='mainContainer'>
<div className='headerContainer'>
<Header/>
</div>
<div className='mainContent'>
<Route exact path='/collection/:collectionId' component={CollectionViewPage}>
<Route exact path='/collection/:collectionId/item/:itemId' component={ItemViewPage}
</div>
<div className='footerContainer'>
<Footer/>
</div>
</div>
</CollectionContext.Provider>
)
}
export default SiteLayout
My thoughts:
I set it up this way, because I cant use useLocation() outside of <BrowserRouter> and I want to update my collectionContext (which contains information about a the collection I'm on, including the collection specific header logo) when the collectionId changes, which is part of the location
My issue:
When I move from item to item, within a collection, the header and everything else still re-renders, for example
from -- /collection/collection1/item/1
to -- /collection/collection1/item/2
My thoughts: This header shouldn't update because the collectionId never changed. However, when I look at the Profiler in React dev tools, it says the Header changed because the parent changed, following it up to <Route> which changed because it's state (location) changed.
What I'm looking for: How do I refactor this to update when the collectionId changes, but not every time the location changes? I need this because (many things, but for simplicity) I want the header to update when I change collections, so that it can use the correct logo, but I would like to prevent the <Header> from re-rendering if I'm navigating around within the collection.
Something else I've tried:I've tried ripping out useLocation and instead using window.location.pathname but nothing will update.
Solved
Using the suggestion from #HMR in response to the original question:
If Header and CollectionViewPage are functional components maybe you can wrap [them] in React.memo If they still re render [...] look at what you need and maybe create a container that only picks what you actually need...
Implementation:
By adding a middle layer, I am able to split location, and then memoize SiteLayout based on whatever part of it I would like.
const MyApp = () => {
// unrelated code
return(
<BrowserRouter>
<LocationPartition>
</BrowserRouter>
)
}
export default MyApp
where in LocationPartition (could probably use a better name):
const LocationPartition = () => {
const location = useLocation()
const collectionId = location.pathname.split('/')[2]
return(
<SiteLayout collection={collectionId}/>
)
}
export default LocationPartition
and so in SiteLayout, I can now memoize based off the collection prop passed from LocationPartition
const SiteLayout = memo(({collection})) => {
const [collData, setCollData] = useState()
useEffect(() => {
const getCollectionData = async() => {
//fetch collection data
//setCollData(fetched data)
}
getCollectionData()
}, [collection])
return(
<CollectionContext.Provider value={collData}>
<div className='mainContainer'>
<div className='headerContainer'>
<Header/>
</div>
<div className='mainContent'>
<Route exact path='/collection/:collectionId' component={CollectionViewPage}>
<Route exact path='/collection/:collectionId/item/:itemId' component={ItemViewPage}
</div>
<div className='footerContainer'>
<Footer/>
</div>
</div>
</CollectionContext.Provider>
)
}
export default SiteLayout
And lo and behold ... React Profiler now only shows re-renders where I want them!