nandorojo / expo-next-react-navigation

⛴ Make Next.js and react-navigation play nicely together with an Expo/React Native Web app.
408 stars 32 forks source link

The future API #68

Open nandorojo opened 3 years ago

nandorojo commented 3 years ago

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
  2. Opening "modals" which don't take you to another "page"
  3. Knowing what to display

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 do linkTo('/artists').

For native, in our linking.config, we define all the mappings of URL → state. On web, we leave linking={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 use stackPresentation: 'modal' with the native-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 using as, 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 my native-stack, I use the same screens, and make sure to give them the same name. 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 use linking.config to map URL → screen. If you need to push multiple of the same screen on top of each other, you should use the getId field on react-navigation's <Stack.Screen /> component.

We should get around from page-to-page like so:

<Link to="/artists/djkhaled"></Link>

// or
linkTo('/artists/djkhaled')

We can open modals like so.

openModal('edit') // this needs intellisense! 

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?

linkTo('/artists/djkhaled/edit')

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:

beatgig.com/search?query=djkhaled

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.

nandorojo commented 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.

cmaycumber commented 3 years ago

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).

nandorojo commented 3 years ago

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.

cmaycumber commented 3 years ago

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.

nandorojo commented 3 years ago

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.

cmaycumber commented 3 years ago

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.

nandorojo commented 3 years ago

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.

nandorojo commented 3 years ago

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.

cmaycumber commented 3 years ago

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.

nandorojo commented 3 years ago

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.

cmaycumber commented 3 years ago

Like this approach a lot. I'll probably start moving some pieces around in my repo today to start moving toward this approach.

nandorojo commented 3 years ago

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.

nandorojo commented 3 years ago

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.

nandorojo commented 3 years ago

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.

nandorojo commented 3 years ago

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.

nandorojo commented 3 years ago

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
})
cmaycumber commented 3 years ago

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
})

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.

nandorojo commented 3 years ago

Out of curiosity, how are you handling navigation now?

cmaycumber commented 3 years ago

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.

nandorojo commented 3 years ago

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

nandorojo commented 3 years ago

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

nandorojo commented 3 years ago

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.

cmaycumber commented 3 years ago

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.

nandorojo commented 3 years ago

You might find some code I added to this POC useful:

https://github.com/axeldelafosse/expo-next-monorepo-example/blob/with-navigation/packages/app/navigation/index.tsx

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.

cmaycumber commented 3 years ago

You might find some code I added to this POC useful:

Definitely going to check this out. Thanks!

nandorojo commented 3 years ago

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]
    ),
  }
}
nandorojo commented 2 years ago

More relevant stuff at https://github.com/axeldelafosse/expo-next-monorepo-example/issues/17

grahammendick commented 2 years ago

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.

gsevla commented 2 years ago

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?

grahammendick commented 2 years ago

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)
nandorojo commented 2 years ago

@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.

gsevla commented 2 years ago

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.