Open nandorojo opened 3 years ago
One crazy cool thing would be to use typescript's new string literal syntax for typed URL paths. Types are essential. But 1) expo doesn't support these for some reason, so we need to figure that out, and 2) I'm not a TS grand wizard.
I guess my biggest question is the single stack approach on native.
Do you have a Tab Navigator in your application, if so how are you choosing to handle it across web and mobile? This has been one of the hardest things for me to solve personally because I would like to have the native tabs on iOS and Android but also provide custom tabs on the web that might render at the top or bottom based on desktop size.
My biggest fear with a single Root stack approach which I use for most screens in my application is losing some of the "behind the scenes" functionality that the Tab Navigator provides (Maybe it's not all that special and a custom tab will work just fine, I really don't know).
I do have a tab navigator. Every tab renders the same stack, the only difference being the initialRouteName
.
The linking.config
lets you determine which tab screen should open in from deep links. It also lets you set the initial route name, such that if you open a deep link to /artists/djkhaled
, you can have the artist page pop on top of home
.
A benefit I've found of a single stack (specifically with Native stack, that's important) is that you can open any screen from anywhere Instagram-style.
Sweet that's awesome. That is a good idea, do you think web would suffer at all performance-wise rendering all the screens for each page?
Otherwise, that approach makes a lot of sense to me, I'll probably start implementing it that way.
Also, you can always jump from one tab to another as long as the tab names don't match the screen names in the nested stack.
That's certainly another hard problem, though.
I suppose I've come to view my app as letting you open any screen from anywhere, where the tabs are just starting points. I know it might not work for every app, but it's the experience I've felt I get when I use Instagram and just open profiles and comments from anywhere.
I suppose I've come to view my app as letting you open any screen from anywhere, where the tabs are just starting points. I know it might not work for every app, but it's the experience I've felt I get when I use Instagram and just open profiles and comments from anywhere.
I think this is definitely the best experience in my opinion as well, and it opens up the opportunity for the largest amount of flexibility. Keeping track of a bunch of navigators quickly becomes a huge headache.
Oh yeah web would definitely suffer, but my web approach is totally different.
On web, next router does too. Except for modals. My current method was to make a react-navigation stack per Next.js page on web.
So on native, I have a single native-stack with every screen.
But on web, I create a stack per-Next.js page. The stacks on web are only there for pages that use modals.
This works very well. The problem is, it doesn't reflect in the URL at all, so my future plan is to (maybe) circumvent React navigation altogether on web, and use my own ModalStack that renders modals based on the next router state.
I have a proof of concept made, but I haven't implemented it much on BeatGig yet since I'm focusing on making our native app currently.
Or did I misread that, are you asking if web suffers from importing every screen from its given stack?
I haven't noted a difference, but you could always use lazy imports. I stopped doing that because it gave me slow startup times in dev and messed with my fast refresh. I've found it to be fine.
No, you read it correctly, I was talking about the root stack in its entirety. I'm less worried about the required modals associated with that stack being imported.
Got it, let me know if I should clarify.
Single stack on native, used in many places based on its initialRouteName
.
Then, a stack per-Next.js page on web, whose screen names match those from the native stack. Or they should open based on URL path, in which case we configure their native screens using linking.config
.
Like this approach a lot. I'll probably start moving some pieces around in my repo today to start moving toward this approach.
I wish I'd done it sooner re the URL paths. I have deep linking set up perfectly thanks to linking config, and the new getId
prop on screens means I no longer even need to use push
(like when I'm opening one artist screen on top of another)
There are some exceptions to the rule here. For instance, you usually don't want the first screen of tab A pushing on top of a screen in tab B. But with the linking config, this would be solved easily.
The alternate API that exists now would be to use navigate
with a nested screen
param. But I find that much less elegant.
Also regarding monorepo. It might just be worth moving core into this package. The only reason I made that was to support Gatsby too. But I think Next is a way better fit for RN and don't plan to maintain the Gatsby lib.
One idea: what if screen names matched next.js page file paths?
<Screen name="/artists/[slug]" />
The reason this is convenient is we could just pass those paths to the linking.config
.
I really like the mental model of URLs over semantic names.
The problem with this idea is I don't see how it could play nicely with nested navigators.
Say we wanted this:
const Routes = { artist: "/artists/[slug]" }
const linking = {
config: { [Routes.artist]: '/artists/:slug' }
}
If possible, this could be really cool. We could even have a function that transforms the Next.js syntax into the React Navigation syntax.
The issue is, what if that artist screen is in a stack, and that stack nested inside of a tab navigator. Suddenly, that URL should really point to the tab. So it should be the tab that has the path for /artists
, and the nested screen that handles the rest of the URL.
Given the nature of nested navigators, I'm not sure how possible this is. But I am certain that I would prefer to stay away from names if possible, so I'm going to give this more thought.
I've been using my modal stack on web for a new big feature I'm building on BeatGig. It's working seriously so well, I am loving it.
Some of the harder problems I faced were deciding when to replace the state vs push to it, how to determine stacking multiple modals on top of each other, etc. I think I have really simple APIs working to address each one.
It comes with an openModal
and closeModal
function, too. Fully typed with your query params/screen names, and they work on native too.
Here are some examples of the code copied and pasted from my repo:
closeModal
examples
// close this detail screen, and remove the bookingRequestId query param
closeModal('BookingRequestDetailSeller', {
paramsToRemove: {
bookingRequestId: true
},
webOverrides: {
replace: false // call Router.push instead of Router.replace
}
})
// close the artists screen, but don't remove the bookingRequestId query param
closeModal('UpdateBookingRequestArtists', {
paramsToRemove: {
bookingRequestId: false
}
})
openModal
examples
// open the detail screen.
// If this screen is already open (ie bookingRequestId exists), replace the navigation state. otherwise, push
openModal('BookingRequestDetailSeller', {
params: { bookingRequestId },
webOverrides: {
replace: (when) => !!when.query.bookingRequestId
}
})
// open a modal with a custom "as" prop on web, useful for refreshing the page and going to the real page
openModal('artist', {
params: {
slug: selectedArtist.slug
},
as: PublicRoutes.artist({ slug: selectedArtist.slug }).web.as
})
Some of the harder problems I faced were deciding when to replace the state vs push to it, how to determine stacking multiple modals on top of each other, etc. I think I have really simple APIs working to address each one.
It comes with an
openModal
andcloseModal
function, too. Fully typed with your query params/screen names, and they work on native too.Here are some examples of the code copied and pasted from my repo:
closeModal
examples// close this detail screen, and remove the bookingRequestId query param closeModal('BookingRequestDetailSeller', { paramsToRemove: { bookingRequestId: true }, webOverrides: { replace: false // call Router.push instead of Router.replace } }) // close the artists screen, but don't remove the bookingRequestId query param closeModal('UpdateBookingRequestArtists', { paramsToRemove: { bookingRequestId: false } })
openModal
examples// open the detail screen. // If this screen is already open (ie bookingRequestId exists), replace the navigation state. otherwise, push openModal('BookingRequestDetailSeller', { params: { bookingRequestId }, webOverrides: { replace: (when) => !!when.query.bookingRequestId } }) // open a modal with a custom "as" prop on web, useful for refreshing the page and going to the real page openModal('artist', { params: { slug: selectedArtist.slug }, as: PublicRoutes.artist({ slug: selectedArtist.slug }).web.as })
This looks pretty promising. Can't wait to see it in action, we're currently working on making all our navigation 100% cross-platform, so I'm excited to see this drop.
Out of curiosity, how are you handling navigation now?
Basically, on mobile, we have a root stack and then some nested navigators e.i drawer, tabs, on the web we treat any of those nested navigators as a web app so we let react-navigation handle the navigation between any of the pages like a replacement for react-router and just use Next Js catch-all routes.
It seems like it's working pretty well for us currently. One of our goals was to somehow be able to define a top-level config and generate both native, and web routes programmatically, along with the expo linking config and a routes file that we could use for navigation. But we haven't found an extremely good way to handle this yet.
I created a gist and explanation for using URLs as state with Expo + Next.js. I'll have to play with it a bit more to ensure it works, but I figured I'd share. Consider the code as-is, though.
https://gist.github.com/nandorojo/052887f99bb61b54845474f324aa41cc
I noticed that I can't batch multiple updates with next/router for the URL state. I opened an issue here https://github.com/vercel/next.js/issues/29240
A bit of a moonshot idea which I don't want to forget...
What if we created a one-to-one mapping of next/router
for native? Link
and useRouting
(and maybe Router.push
if we could pass a ref
to navigation container...)
All routing would happen via URL/query param triggers. The query
from Router.push
is serializable anyway.
What if we just made an alias inside of the babel config for our expo entry 🧐
I haven't thought through implementation at all. Seems a bit pie in the sky, and might be too hard to maintain. But something to think about.
If it helps brainstorm a bit, we're currently handling something similar to one-to-one routing by using a beefed-up linkingConfig for react-navigation to generate all our screens + next pages.
On Next this works by using a catch-all [[...slug.tsx]] and generating the pages with the getStaticPaths function, this also allows us to type our routes with a wrapper around this lib. It could definitely be better but it does handle the navigation across native and web at least.
This solution definitely has downsides but it's something we found that's working somewhat in the meantime.
What if we created a one-to-one mapping of next/router for native? Link and useRouting (and maybe Router.push if we could pass a ref to navigation container...)
This idea is awesome. I feel like this might clean up a lot of code in our app.
You might find some code I added to this POC useful:
We trigger all navigations via URL. And on web, we use Next Router for everything, including modal changes. Whenever next/router has a routeChangeComplete, we call linkTo
(on Web). On native, we call linkTo
directly whenever we navigate.
There are many hacks, but I think that repo (on the with-navigation
branch) has a lot of potential. @axeldelafosse put it together, and I made a few small contributions. I'm really interested to see if we can make it a scalable solution.
You might find some code I added to this POC useful:
Definitely going to check this out. Thanks!
I've found this to be a really useful library for code-gen'd paths from Next.js: https://github.com/aspida/pathpida
As shown in the PR above, this is (roughly) how I use it:
import { useCallback } from 'react'
import { useRouter as useNextRouter, NextRouter } from 'next/router'
import { useLinkTo, useNavigation } from '@react-navigation/native'
const path = (from: Parameters<ReturnType<typeof useRouter>['push']>[0]) => {
let path = (typeof from == 'string' ? from : from.pathname) || ''
// replace each instance of [key] with the corresponding value from query[key]
// this ensures we're navigating to the correct URL
// it currently ignores [...param]
// but I can't see why you would use this with RN + Next.js
if (typeof from == 'object' && from.query && typeof from.query == 'object') {
for (const key in from.query) {
if (from.query[key] != null) {
path = path.replace(`[${key}]`, `${from.query[key]}`)
}
}
}
return path
}
export function useRouter() {
const linkTo = useLinkTo()
const router = useNextRouter()
const navigation = useNavigation()
return {
push: useCallback(
(...nextProps: Parameters<NextRouter['push']>) => {
if (router) {
router.push(...nextProps)
} else {
const [url, as] = nextProps
const to = as ? path(as) : path(url)
if (to) {
linkTo(to)
}
}
},
[linkTo, router]
),
replace: useCallback(
(...nextProps: Parameters<NextRouter['replace']>) => {
if (router) {
router.replace(...nextProps)
} else {
const [url, as] = nextProps
const to = as ? path(as) : path(url)
if (to) {
linkTo(to)
}
}
},
[linkTo, router]
),
back: useCallback(
(...nextProps: Parameters<NextRouter['back']>) => {
if (router) {
router.back(...nextProps)
} else {
navigation.goBack()
}
},
[router, navigation]
),
}
}
More relevant stuff at https://github.com/axeldelafosse/expo-next-monorepo-example/issues/17
Hi, my Navigation router elegantly solves these problems you’re working on. Before I get into the Navigation router API, you should take a look at what it can do. That's the native Twitter app running on the web. You can see that Hyperlinks navigate forward and back through the stack. Even the button that opens the Modal is a Hyperlink. What's more, the same single codebase also runs on Android and iOS where the navigation is 100% native.
I'll draw your attention to some other interesting features of the Twitter demo app. Notice that it is genuine stack navigation on the web. The scenes in the stack stay mounted just like they do on native. Look how well the stack plays with browser history. The back arrow takes the user back in browser history so that it feels like a real stack. But also notice that the back arrow is a real Hyperlink. You can CMD-click it and open it in a new tab. When opened in a new tab, the browser history is primed so that it matches where the user is in the stack.
Let me tell you a bit about the Navigation router API. There's a navigate
function for pushing a new scene onto the stack. You pass the name of the scene along with any navigation data.
stateNavigator.navigate('artist', {name: 'djkhaled'});
There’s an associated getNavigationLink
function that returns the URL for the navigation. So here's a Hyperlink that pushes the artist scene onto the stack,
<Button
href={stateNavigator.getNavigationLink(('artist', {name: 'djkhaled'})}
onPress={(e) => {
e.preventDefault();
stateNavigator.navigate('artist', {name: 'djkhaled'});
}}>
You might be wondering what the URL looks like. By default, the Navigation router generates URLs based on the name of the scene and passes data in the query string, '/artist?name=djkhaled'. But you can change that by configuring a route. For example, if you configure a route of '/artist/{name}' then the Navigation router generates a URL of '/artist/djkhaled' instead. With the Navigation router, URLs aren’t hard-coded. So you can change them to whatever you want without changing any code.
You access the navigation data from the NavigationContext
. So to get the artist’s name,
const { data } = useContext(NavigationContext);
const { name } = data;
For popping a scene from the top of the stack, there's navigateBack
and getNavigationBackLink
functions. You pass the number of scenes you want to pop. Here's a button that goes back to the previous scene,
<Button
href={stateNavigator.getNavigationBackLink(1)}
onPress={(e) => {
e.preventDefault();
stateNavigator.navigateBack(1});
}}>
The last bit of the navigation API is refresh navigation. This is where you navigate but stay on the same scene, like opening a Modal.
const { data } = useContext(NavigationContext);
const { name, edit } = data;
<Button
href={stateNavigator.getRefreshLink({name: ‘djkhaled’, edit: true})}
onPress={(e) => {
e.preventDefault();
stateNavigator.refresh({name: ‘djkhaled’, edit: true});
}}>
<Modal visible={edit}>
All these three different types of navigation feature in the Twitter app demo.
Don't know here is the right place to talk about this but: what about navigation services?
Sometimes we want/need to navigate from outside a navigator and it can be achieved by using a service as described here https://reactnavigation.org/docs/navigating-without-navigation-prop for react native and Router.back for nextjs.
In this case we need to make our code check in which platform we are.
So I was thinking that we could have a way to do that under the hood.
For instance, I needed a go back service and wrote it like this:
import Router from 'next/router';
import { createNavigationContainerRef } from '@react-navigation/native';
import { Platform } from 'react-native';
export const navigationRef = createNavigationContainerRef();
export function goBackService() {
if (Platform.OS === 'web') {
Router.back();
} else {
if (navigationRef.isReady()) {
navigationRef.goBack();
}
}
}
Does it make sense for you?
Hi @gsevla, you should look at what my Navigation router can do. It has a single API for Android, iOS and Web and it 100% respects the platform primitives. If you call navigateBack
then it will do a native pop navigation on Android and iOS, and a browser history back on Web.
stateNavigator.navigateBack(1)
@gsevla I'm working on a solution that will allow you to navigate via ref using a middleware approach.
But yes, the code you wrote there looks valid to me.
PS I added tsx
to your code sample to format it.
Hey guys @grahammendick @nandorojo, sorry for not answering you before, I went through some issues and couldn't do it.
Your solution looks very nice @grahammendick, congrats and thanks for the tip.
@nandorojo thanks man! I'm already excited about this new feature. Right now I'm using this lib on a personal project called MyTable and loving it.
I'm going to jot down some thoughts about what navigation should look like. Feedback welcome.
There are a few different concepts to deal with:
1. Getting around from one page to another
I've found that the most elegant solution to get around an app is a URL. Instead of
navigate('artists')
, we should dolinkTo('/artists')
.For native, in our
linking.config
, we define all the mappings of URL → state. On web, we leavelinking={undefined}
, and defer to Next file system routing for getting around from page-to-page.2. Modals 😱
This is a tricky one. Sometimes, modals should be reflected in URLs. But maybe there are cases where they shouldn't.
Here's the solution I think could work. Create a web-only modal stack per Next.js page. The modal should show based on the URL. Example in this tweet.
Each "modal" (which is technically a screen) would pop on top of the base screen, depending on the URL. This is all handled by Next.js' shallow routing. To close it, you just
goBack
. Meanwhile, on native, you just usestackPresentation: 'modal'
with thenative-stack
.Caveats
Are there times when you want to open modals but not change the URL? Presumably you could "shallow" route to the current
pathname
on web usingas
, but then it won't deep link there on native. I guess that's fine. Need to give it some thought.3. What to display
React Navigation handles this on native elegantly.
On web, next router does too. Except for modals. My current method was to make a
react-navigation
stack per Next.js page on web. For mynative-stack
, I use the same screens, and make sure to give them the samename
. Holy shit is that a fragile approach. Luckily TypeScript ensures that I use the same screen names (I guess), but I really don't like it. URLs are much more reliable IMO.What's next
I think all APIs should be consolidated into URLs. Stacks on web should render per-page based on the URL. This helps with code splitting too, I suppose.
On native, you should have a single
native-stack
that has all of your screens. You should uselinking.config
to map URL → screen. If you need topush
multiple of the same screen on top of each other, you should use thegetId
field on react-navigation's<Stack.Screen />
component.We should get around from page-to-page like so:
We can open modals like so.
Now that I think about it, maybe opening modals should be constrained to URLs too. The only problem is, they're less typed.
Maybe it should be this?
Oof, I don't know. This is hard.
What about query parameters?
There needs to be a better abstraction for using query parameters as state.
I often want to do this:
I end up creating a
useQuery.web.ts
that uses the URL params on web, and local state on native. Don't love that approach. We need something more standardized.