Make react-router-dom v6 pass path as key to rendered element - reactjs

I think I may need a paradigm shift in my thinking here, so I'm open to those kinds of answers as well here.
Consider the following simplified example:
export const App = (p: { apiClient: SomeApiClient }) => {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/posts/:postId" element={<Post apiClient={p.apiClient} />} />
</Routes>
);
}
export const Home = () => {
return <h1>Home!</h1>
}
export const Post = (p: { apiClient: SomeApiClient }) => {
const { postId } = useParams();
const [ state, setState ] = useState<PostState>({ status: "loading" });
// When the component mounts, get the specified post from the API
useEffect(() => {
if (state.status === "loading") {
(async () => {
const post = await p.apiClient.getPost(postId);
setState({ status: "ready", post });
})();
}
})
return (
<h2>Posts</h2>
{
state.status === "loading"
? <p>Loading....</p>
: <div className="post">
<h3>{state.post.title}</h3>
<div className="content">{state.post.content}</div>
</div>
}
)
}
export type PostState =
| { status: "loading" }
| { status: "ready"; post: BlogPost };
export type BlogPost = { title: string; content: string };
This works fine the first time, but pretend there's a <Link /> on the page that goes to the next post. When I click that link, the URL changes, but the page content doesn't, because React Router is not actually re-mounting the <Post .../> component. That component correctly receives the updated postId and is re-rendered, but since it doesn't get re-mounted, the useEffect logic doesn't run again and the content stays the same.
I've been solving this very awkwardly by creating intermediary components like so:
export const App = (p: { apiClient: SomeApiClient }) => {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/posts/:postId" element={<PostRenderer apiClient={p.apiClient} />} />
</Routes>
);
}
export const PostRenderer = (p: { apiClient: SomeApiClient }) => {
const { postId } = useParams();
return <Post key={postId} postId={postId} apiClient={p.apiClient} />
}
export const Post = (p: { postId: string; apiClient: SomeApiClient }) => {
// ....
}
But I'm starting to get a lot of those, and literally all they do is take the param from the URL and use it as a key on the actual target component. I've read through the react-router-dom docs and am not finding anything that indicates there's a way to automate this. I must be thinking about this wrong.... Any suggestions are appreciated.

I think a more common and practical solution is to add the postId as a dependency to the useEffect to rerun the asynchronous logic when the route param changes.
Example:
export const Post = (p: { apiClient: SomeApiClient }) => {
const { postId } = useParams();
const [state, setState] = useState<PostState>({ status: "loading" });
// When the post id updates, get the specified post from the API
useEffect(() => {
const fetchPostById = async (postId) => {
setState({ status: "loading" });
const post = await p.apiClient.getPost(postId);
setState({ status: "ready", post });
};
fetchPostById(postId);
}, [postId]);
return (
<h2>Posts</h2>
{
state.status === "loading"
? <p>Loading....</p>
: <div className="post">
<h3>{state.post.title}</h3>
<div className="content">{state.post.content}</div>
</div>
}
)
};

Related

Problem when dynamically registering routes in an application with microfrontends concept

I have an Typescript + Redux (with RTK) application using the microfrontends concept. All the steps for the construction came from this tutorial: Microfrontends tutorial.
The main component is Microfrontend.tsx (omitted imports):
interface Manifest {
files: {
'main.js': string
'main.js.map': string
'index.html': string
}
entrypoints: string[]
}
const MicroFrontend = ({
name,
host,
module
}: {
name: string
host: string | undefined
module: string
}) => {
const history = useHistory()
useEffect(() => {
const renderMicroFrontend = () => {
// #ts-ignore
window[`render${name}`] && window[`render${name}`](`${name}-container`, history)
}
if (document.getElementById(name)) {
renderMicroFrontend()
return
}
const manifestUrl = `${
isDevProfile ? host : ''
}/${module}/view/asset-manifest.json`
fetch(manifestUrl)
.then(res => res.json())
.then((manifest: Manifest) => {
const script = document.createElement('script')
script.id = name
script.crossOrigin = ''
script.src = `${host}${manifest.files['main.js']}`
script.onload = () => {
renderMicroFrontend()
}
document.head.appendChild(script)
})
return () => {
// #ts-ignore
window[`unmount${name}`] && window[`unmount${name}`](`${name}-container`)
}
})
return (
<main id={`${name}-container`} style={{ height: '100%' }} />
)
}
MicroFrontend.defaultProps = {
document,
window
}
export default MicroFrontend
I'm trying to render the routes of the child components in a dynamic way, however, when I do this, I have a very strange effect: Bug.
The code snippet that generates this effect is this (omitted imports):
const App = () => {
const dispatch = useAppDispatch()
const { loadWithSuccess } = useSelector(moduleSelectors)
const avaibleModuleLinks = useSelector(avaibleModuleLinksWhitoutHome)
useEffect(() => {
dispatch(fetchAvaibleModules()).then(response =>
dispatch(fetchAvaibleModuleLinks(response.payload as string[]))
)
}, [dispatch])
return (
<BrowserRouter>
<Template>
<Switch>
<Route exact={true} path="/" component={Home} />
{loadWithSuccess ? avaibleModuleLinks?.map(
(subMenuPath: SubMenuPath | undefined, index: number) => {
const subMenuPathKey = subMenuPath ? subMenuPath.key : ''
let micro = () => (
<MicroFrontend
module={subMenuPathKey}
host="127.0.0.1"
name={subMenuPath ? subMenuPath.key.charAt(0).toUpperCase() : ''}
/>
)
return (
<Route
key={index}
path={`/dfe/view/${subMenuPathKey}`}
component={micro}
/>
)
}
): <></>}
</Switch>
</Template>
</BrowserRouter>
)
}
export default App
Only when I don't render routes dynamically do I have the desired effect: desired behavior
The code snippet that generates this effect is this (omitted imports):
const ModuleNfe = () => (
<MicroFrontend host="127.0.0.1" name="Nfe" module="nfe" />
)
const App = () => {
const dispatch = useAppDispatch()
const { loadWithSuccess } = useSelector(moduleSelectors)
const avaibleModuleLinks = useSelector(avaibleModuleLinksWhitoutHome)
useEffect(() => {
dispatch(fetchAvaibleModules()).then(response =>
dispatch(fetchAvaibleModuleLinks(response.payload as string[]))
)
}, [dispatch])
return (
<BrowserRouter>
<Template>
<Switch>
<Route exact={true} path="/" component={Home} />
<Route path="/dfe/view/nfe" component={ModuleNfe} />
</Switch>
</Template>
</BrowserRouter>
)
}
export default App
As you may have noticed, the desired behavior is for my page to be rendered inside the Template component. But for some reason, this is not the case.

How to add page number to the URL

Could someone please tell me how can I add page number to my url. The component is as follows:
/** NPM Packages */
import React, { Component } from "react";
import { connect } from "react-redux";
import { Spinner, Pagination } from "react-bootstrap";
//import styles from "./App.module.css";
/** Custom Packages */
import List from "../List";
//import fetchCategories from "../../../actions/configuration/category/fetchCategories";
import deleteCategory from "../../../actions/configuration/category/deleteCategory";
import API from "../../../../app/pages/utils/api";
class Category extends Component {
constructor(props) {
super(props);
this.state = {
mesg: "",
mesgType: "",
isLoading: true,
total: null,
per_page: null,
current_page: 1,
pdata: []
};
this.fetchCategoriesAPI = this.fetchCategoriesAPI.bind(this);
}
fetchCategoriesAPI = async pno => {
await API.get("categories?offset=" + (pno.index+1))
.then(res => this.setState({ pdata: res.data }))
.then(() => this.props.passToRedux(this.state.pdata))
.catch(err => console.log(err));
};
componentDidMount = async () => {
const { state } = this.props.location;
if (state && state.mesg) {
this.setState({
mesg: this.props.location.state.mesg,
mesgType: this.props.location.state.mesgType
});
const stateCopy = { ...state };
delete stateCopy.mesg;
this.props.history.replace({ state: stateCopy });
}
this.closeMesg();
await this.fetchCategoriesAPI(1);
this.setState({ isLoading: false });
};
onDelete = async id => {
this.props.removeCategory(id);
await deleteCategory(id).then(data =>
this.setState({ mesg: data.msg, mesgType: "success" })
);
this.closeMesg();
};
closeMesg = () =>
setTimeout(
function() {
this.setState({ mesg: "", mesgType: "" });
}.bind(this),
10000
);
/** Rendering the Template */
render() {
let activePage = this.state.pdata.currPage;
let items = [];
let totalPages = Math.ceil(this.state.pdata.totalCount / 10);
for (let number = 1; number <= totalPages; number++) {
items.push(
<Pagination.Item key={number} active={number == activePage}>
{number}
</Pagination.Item>
);
}
const paginationBasic = (
<div>
<Pagination>
{items.map((item,index)=>{
return <p key={index} onClick={() => this.fetchCategoriesAPI({index})}>{item}</p>
})}
</Pagination>
<br />
</div>
);
const { mesg, mesgType, isLoading } = this.state;
return (
<>
{mesg ? (
<div
className={"alert alert-" + mesgType + " text-white mb-3"}
role="alert"
>
{mesg}
</div>
) : (
""
)}
{isLoading ? (
<div className="container-fluid">
<h4
className="panel-body"
style={{ "text-align": "center", margin: "auto" }}
>
Loading
<Spinner animation="border" role="status" />
</h4>
</div>
) : (
<div>
<List
listData={this.props.categories}
listName="category"
_handleDelete={this.onDelete.bind(this)}
/>
{paginationBasic}
</div>
)}
</>
);
}
}
const matchStatestoProps = state => {
return { categories: state.categories };
};
const dispatchStatestoProps = dispatch => {
return {
passToRedux: pload =>
dispatch({ type: "FETCH_CATEGORIES", payload: pload }),
removeCategory: id => dispatch({ type: "DELETE_CATEGORY", payload: id })
};
};
export default connect(matchStatestoProps, dispatchStatestoProps)(Category);
the route is as follows:
<Route exact path="/categories/:page?" component={Category} />
So basically I want the page number to be displayed in the URL. Also if I change the page number, the data should load the corresponding page. Please help me
Could someone please help me out?
In a class component:
Your router will pass match in as a prop. When your component mounts, get this.props.match.params.page and load the data accordingly:
class MyComponent extends React.Component {
componentDidMount () {
// get the 'page' param out of the router props.
// default to 0 if not specified.
const { page = 0 } = this.props.match.params;
// it comes in as a string, parse to int
const p = parseInt(page, 10);
// do whatever you need to do (load data, etc.)
}
}
In a function component:
In a function component, you can get the page param via react-router's useParams hook:
import { useParams } from 'react-router-dom';
function MyComponent () {
const { page } = useParams(); // get the 'page' router param
const p = parseInt(page, 10); // comes in as a string, convert to int
// do whatever you need to do with it
}
If you need prev/next navigation you can deduce those page numbers from the current page.
I made this quick example that demonstrates how to access and use the route's url parameters via react router's useParams hook and how to do it via the match prop with a class component.
You can get page number from props like this:
const matchStatestoProps = (state, ownProps) => {
return { id: ownProps.match.params.id; categories: state.categories };
};
In your routes:
<Route path="/page/:id" component={Page} />

How to pass data queried in one component to another component to use as a query variable?

I'm stuck trying to pass a value queried with Apollo Client in one route component to another route component to use as a variable in a query. The exact error is: "Uncaught TypeError: Cannot read property 'name' of undefined".
There are three components:
App, the root component with the router.
ComponentA, that queries a group of data by id and name to show a list of Cards for each item. Every item has a link to a to ComponentB.
Component B, which has to query more data using the name referenced by ComponentA as a variable to show more data from that item.
App.tsx
export const App: React.FunctionComponent = () => {
return (
<BrowserRouter>
<>
<Main>
<Switch>
<Route exact path="/" component={ComponentA} />
<Route path="/:name" component={ComponentB} />
</Switch>
</Main>
</>
</BrowserRouter>
);
};
ComponentA.tsx
const GET_DATAS = gql`
query GetDatas {
getDatas {
_id
name
}
}
`;
interface Data {
_id: string;
name: string;
}
export const Home: React.FunctionComponent = () => {
const { data } = useQuery(GET_DATAS);
return (
<>
<div>
{data.getDatas.map((data: Data) => (
<Link to={`/${data.name}`} key={data._id}>
<Card name={data.name} />
</Link>
))}
</div>
</>
);
};
ComponentB.tsx
const GET_DATA = gql`
query GetData($name: String!) {
getData(name: $name) {
_id
name
year
color
}
}
`;
interface Props {
name: string;
}
export const DataDetails: React.FunctionComponent<Props> = (props: Props) => {
const { data } = useQuery(GET_DATA, {
variables: { name },
});
return (
<>
<div>
<H1>{data.getData.name}</H1>
<p>{data.getData.year}</p>
<p>{data.getData.color}</p>
</div>
</>
);
};
The queries work well as I tested them in Playground, and I tried using local state and passing the props with Link with no results, but I still can't figure out how to pass the value to use in the query of ComponentB.
Thanks in advance!
Fixed 🎉 I finally opted for just taking the URL, cleaning it a bit, and using it as a variable for the query, and also adding the loading and error states:
export const DataDetails: React.FunctionComponent = () => {
const dirtyPath = location.pathname;
const cleanPath = dirtyPath.replace(/%20/g, ' ').replace(/\//g, '');
const { data, loading, error } = useQuery(GET_DATA, {
variables: { name: cleanPath },
});
return (
...
);
};
Another solution, available when using React Router, would be:
export const DataDetails: React.FunctionComponent = (props) => {
const { data, loading, error } = useQuery(GET_DATA, {
variables: { name: props.match.params.name },
});
return (
...
);
};

Firebase/React/Redux Component has weird updating behavior, state should be ok

I am having a chat web app which is connected to firebase.
When I refresh the page the lastMessage is loaded (as the gif shows), however, for some reason, if the component is otherwise mounted the lastMessage sometimes flickers and disappears afterwards like it is overridden. When I hover over it, and hence update the component, the lastMessage is there.
This is a weird behavior and I spent now days trying different things.
I would be very grateful if someone could take a look as I am really stuck here.
The db setup is that on firestore the chat collection has a sub-collection messages.
App.js
// render property doesn't re-mount the MainContainer on navigation
const MainRoute = ({ component: Component, ...rest }) => (
<Route
{...rest}
render={props => (
<MainContainer>
<Component {...props} />
</MainContainer>
)}
/>
);
render() {
return (
...
<MainRoute
path="/chats/one_to_one"
exact
component={OneToOneChatContainer}
/>
// on refresh the firebase user info is retrieved again
class MainContainer extends Component {
componentDidMount() {
const { user, getUserInfo, firebaseAuthRefresh } = this.props;
const { isAuthenticated } = user;
if (isAuthenticated) {
getUserInfo(user.id);
firebaseAuthRefresh();
} else {
history.push("/sign_in");
}
}
render() {
return (
<div>
<Navigation {...this.props} />
<Main {...this.props} />
</div>
);
}
}
Action
// if I set a timeout around fetchResidentsForChat this delay will make the lastMessage appear...so I must have screwed up the state / updating somewhere.
const firebaseAuthRefresh = () => dispatch => {
firebaseApp.auth().onAuthStateChanged(user => {
if (user) {
localStorage.setItem("firebaseUid", user.uid);
dispatch(setFirebaseAuthUser({uid: user.uid, email: user.email}))
dispatch(fetchAllFirebaseData(user.projectId));
}
});
};
export const fetchAllFirebaseData = projectId => dispatch => {
const userId = localStorage.getItem("firebaseId");
if (userId) {
dispatch(fetchOneToOneChat(userId));
}
if (projectId) {
// setTimeout(() => {
dispatch(fetchResidentsForChat(projectId));
// }, 100);
...
export const fetchOneToOneChat = userId => dispatch => {
dispatch(requestOneToOneChat());
database
.collection("chat")
.where("userId", "==", userId)
.orderBy("updated_at", "desc")
.onSnapshot(querySnapshot => {
let oneToOne = [];
querySnapshot.forEach(doc => {
let messages = [];
doc.ref
.collection("messages")
.orderBy("created_at")
.onSnapshot(snapshot => {
snapshot.forEach(message => {
messages.push({ id: message.id, ...message.data() });
});
});
oneToOne.push(Object.assign({}, doc.data(), { messages: messages }));
});
dispatch(fetchOneToOneSuccess(oneToOne));
});
};
Reducer
const initialState = {
residents: [],
oneToOne: []
};
function firebaseChat(state = initialState, action) {
switch (action.type) {
case FETCH_RESIDENT_SUCCESS:
return {
...state,
residents: action.payload,
isLoading: false
};
case FETCH_ONE_TO_ONE_CHAT_SUCCESS:
return {
...state,
oneToOne: action.payload,
isLoading: false
};
...
Main.js
// ...
render() {
return (...
<div>{React.cloneElement(children, this.props)}</div>
)
}
OneToOne Chat Container
// without firebaseAuthRefresh I don't get any chat displayed. Actually I thought having it inside MainContainer would be sufficient and subscribe here only to the chat data with fetchOneToOneChat.
// Maybe someone has a better idea or point me in another direction.
class OneToOneChatContainer extends Component {
componentDidMount() {
const { firebaseAuthRefresh, firebaseData, fetchOneToOneChat } = this.props;
const { user } = firebaseData;
firebaseAuthRefresh();
fetchOneToOneChat(user.id || localStorage.getItem("firebaseId"));
}
render() {
return (
<OneToOneChat {...this.props} />
);
}
}
export default class OneToOneChat extends Component {
render() {
<MessageNavigation
firebaseChat={firebaseChat}
firebaseData={firebaseData}
residents={firebaseChat.residents}
onClick={this.selectUser}
selectedUserId={selectedUser && selectedUser.residentId}
/>
}
}
export default class MessageNavigation extends Component {
render() {
const {
onClick,
selectedUserId,
firebaseChat,
firebaseData
} = this.props;
<RenderResidentsChatNavigation
searchChat={this.searchChat}
residents={residents}
onClick={onClick}
firebaseData={firebaseData}
firebaseChat={firebaseChat}
selectedUserId={selectedUserId}
/>
}
}
const RenderResidentsChatNavigation = ({
residents,
searchChat,
selectedUserId,
onClick,
firebaseData,
firebaseChat
}) => (
<div>
{firebaseChat.oneToOne.map(chat => {
const user = residents.find(
resident => chat.residentId === resident.residentId
);
const selected = selectedUserId == chat.residentId;
if (!!user) {
return (
<MessageNavigationItem
id={chat.residentId}
key={chat.residentId}
chat={chat}
onClick={onClick}
selected={selected}
user={user}
firebaseData={firebaseData}
/>
);
}
})}
{residents.map(user => {
const selected = selectedUserId == user.residentId;
const chat = firebaseChat.oneToOne.find(
chat => chat.residentId === user.residentId
);
if (_isEmpty(chat)) {
return (
<MessageNavigationItem
id={user.residentId}
key={user.residentId}
chat={chat}
onClick={onClick}
selected={selected}
user={user}
firebaseData={firebaseData}
/>
);
}
})}
</div>
}
}
And lastly the item where the lastMessage is actually displayed
export default class MessageNavigationItem extends Component {
render() {
const { hovered } = this.state;
const { user, selected, chat, isGroupChat, group, id } = this.props;
const { messages } = chat;
const item = isGroupChat ? group : user;
const lastMessage = _last(messages);
return (
<div>
{`${user.firstName} (${user.unit})`}
{lastMessage && lastMessage.content}
</div>
)
}
In the end it was an async setup issue.
In the action 'messages' are a sub-collection of the collection 'chats'.
To retrieve them it is an async operation.
When I returned a Promise for the messages of each chat and awaited for it before I run the success dispatch function, the messages are shown as expected.

Code splitting route components wrapped in a HOC with React Loadable

I am running into problems using React Loadable with route based code splitting using Webpack 3.11.
When I try to render my app on the server my async modules immediately resolve without waiting for the promise. Thus the SSR output becomes <div id="root"></div>.
App.js:
const App = () => (
<Switch>
{routes.map((route, index) => (
<Route key={index} path={route.path} render={routeProps => {
const RouteComponent = route.component
return <RouteComponent {...routeProps} />
}} />
))}
</Switch>
)
I've defined my async route components with React Loadable like this:
Routes.js
function Loading ({ error }) {
if (error) {
return 'Oh nooess!'
} else {
return <h3>Loading...</h3>
}
}
const Article = Loadable({
loader: () => import(/* webpackChunkName: "Article" */ '../components/contentTypes/Article'),
loading: Loading
})
const Page = Loadable({
loader: () => import(/* webpackChunkName: "Page" */ '../components/contentTypes/Page'),
loading: Loading,
render (loaded, props) {
let Component = WithSettings(loaded.default)
return <Component {...props}/>
}
})
export default [
{
path: `/:projectSlug/:env${getEnvironments()}/article/:articleSlug`,
component: Article,
exact: true
},
{
path: `/:projectSlug/:env${getEnvironments()}/:menuSlug?/:pageSlug?`,
component: Page
}
]
WithSettings.js
export default (WrappedComponent: any) => {
class WithSettings extends React.Component<WithSettingsProps, WithSettingsState> {
static displayName = `WithSettings(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`
state = {
renderWidth: 1200
}
componentDidMount () {
this.loadSettings({ match: { params: { projectSlug: '', env: '' } } })
window.addEventListener('resize', this.onResize)
this.onResize()
}
componentWillUnmount () {
if (isClient) {
window.removeEventListener('resize', this.onResize)
}
}
componentDidUpdate (oldProps) {
this.loadSettings(oldProps)
}
onResize = () => {
this.setState({ renderWidth: this.getLayoutWidth() })
}
getLayoutWidth () {
return (document.body && document.body.offsetWidth) || 0
}
loadSettings (oldProps) {
const { settings, request, getNewSettings } = this.props
const { projectSlug: oldProjectSlug, env: oldEnv } = oldProps.match.params
const { projectSlug: newProjectSlug, env: newEnv } = this.props.match.params
if (
(
oldProjectSlug !== newProjectSlug ||
oldEnv !== newEnv
) ||
(
settings === undefined ||
(request.networkStatus === 'ready')
)
) {
getNewSettings()
}
}
render () {
const { settings, request, history, location, match } = this.props
const { renderWidth } = this.state
if (!settings || !request || request.networkStatus === 'loading') {
return <div />
}
if (request.networkStatus === 'failed') {
return <ErrorBlock {...getErrorMessages(match.params, 'settings')[4044]} fullscreen match={match} />
}
return (
<WrappedComponent
settings={settings}
settingsRequest={request}
history={history}
location={location}
match={match}
renderWidth={renderWidth}
/>
)
}
}
hoistNonReactStatic(WithSettings, WrappedComponent)
return connect(mapStateToProps, mapDispatchToProps)(WithSettings)
}
I've managed to narrow it down to the WithSettings HOC that I am using to wrap my route components in. If I don't use the WithSettings HOC (as with the Article route) then my SSR output waits for the async import to complete, and the server generated html includes markup related to the route (good!). If I do use the HOC (as with the Page route) then the module immediately resolves and my SSR output turns into <div id="root"></div because it no longer waits for the dynamic import to complete before rendering. Problem is: I need the WithSettings HOC in all my routes as it fetches required data from the server that I need to render the app.
Can anyone tell me how I can use a HOC and use React Loadable's async component for route components so that it works on the server?
Managed to figure it out.
It was due to my HOC. In the render method it would return <div /> when settings or request where undefined or request.networkStatus is loading. It turns out this tripped up server side rendering. Removing this block was enough to make it work.

Resources