named-urls icon indicating copy to clipboard operation
named-urls copied to clipboard

New API using closures

Open tricoder42 opened this issue 7 years ago • 6 comments

Instead of reverse(path, { params }) what about simply call path({ params })?

Consider following routes:

// routes.js
export default {
   // simple route
   profile: '/profile',
   
   // route with params
   article: '/article/:articleId',
}

Each named route is a function, which accepts route params:

import routes from './routes'

const ProfilePage() {
  return (
    <div>
        <h1>My articles</h1>

        {articles.map(article => (
            <Link to={routes.article({ articleId: article.id })}>{article.name}</Link>
        )}
    </div>
  )
}

Also, each route also has a path attribute, which returns raw pattern:

import routes from './routes'

const App() {
  return (
    <Switch>
        <Route path={routes.profile.path} component={ProfilePage} />
        <Route path={routes.article.path} component={ArticlePage} />
    </Switch>
  )
}

Advantages:

  • It simplifies the API a lot. Instead of importing reverse and routes, is enough to import simply routes. I don't think there's any overhead, because routes are constructed only once when the app is loaded.
  • Right now, nested routes (created using include) must use toString() on root route, otherwise object of child routes is returned. Having path attribute would unify the API.

Disadvantages:

  • path attribute might clash with a route named path in child routes

What do you think @almaron?

tricoder42 avatar Nov 27 '18 08:11 tricoder42

What about included routes?

almaron avatar Nov 27 '18 10:11 almaron

It's the same:

// routes.js
export default {
   auth: include('/auth', {
      login: '/login',
      reset: '/reset/:token'
   })
}

Using pattern:

<Route path={routes.auth.path} /> // used to be `routes.auth.toString()`
<Route path={routes.auth.reset.path} />

Reversing:

<Link to={routes.auth()} />
<Link to={routes.auth.reset({ token: 'xyz' }) />

tricoder42 avatar Nov 27 '18 10:11 tricoder42

I like this idea. But maybe there should be one additional piece:

Routes as functions:

export default {
   auth: include('/auth', {
      login: '/login',
      reset: (token) => { return `/reset/${token}`; }
   })
}

It's of course not useful for such simple scenarios, but imagine you have recursive routes and / or array parameters and / or object parameters. Something like:

export default {
   children: include('/children', {
      firstChild: '/:id',
      multiple: (ids) => { return `/${ids.join('/')}`; }, // will result in /children/4/3/1/2/...
      complexParam: (fooBar) => { return `/${fooBar.str}/${fooBar.bool ? 'foo' : 'bar'}` }
   })
}

And of course there must be a way to set the :param stuff for react router:

export default {
   auth: include('/auth', {
      login: '/login',
      reset: (token = ":token") => { return `/reset/${token}`; }
   })
}

Now when you call reset() it will result in /reset/:token and reset('foo') will result in /reset/foo

benneq avatar Dec 11 '18 13:12 benneq

Interesting idea!

How would you provide :params for react router for your example?

export default {
   children: include('/children', {
      firstChild: '/:id',
      multiple: (ids) => { return `/${ids.join('/')}`; }, // will result in /children/4/3/1/2/...
      complexParam: (fooBar) => { return `/${fooBar.str}/${fooBar.bool ? 'foo' : 'bar'}` }
   })
}

tricoder42 avatar Dec 11 '18 14:12 tricoder42

The main reason behind this idea was, that I'm using TypeScript. And I like to have my IDE telling me what's allowed or not. And the easiest way is having function arguments :)

In the last example I showed a simple usage for :params using "default arguments":

foo: include('/foo',
  bar: (baz = ':baz') => { return `/${baz}` }
)

This function will return /:baz if you provide no arguments (= calling bar()). And else it will use the provided argument. So you would use something like <Route path={Routes.foo.bar()} /> and <Link to={Routes.foo.bar("123")} />.

And otherwise: It's a function, you can do whatever you want:

include('/foo',
  bar: (baz) => {
    if(baz === undefined) return "/:whateveryouwant";
    else if(typeof baz === "number") return "/"+(baz+42);
    else return `/${baz}`;
  }
)

But now I came across another possible issue with this... It may be necessary (or "useful") to have access to react router's match.url. Especially for recursive routes (see: https://reacttraining.com/react-router/web/example/recursive-paths ). I'm not sure if that's even possible to inject the match.url.


EDIT: I now built a simplified version of your code, which handles only functions. That was pretty straight forward. The only downside is that for every level of nesting the function chain gets longer and longer.

const include = (base, routes) => {
    return Object.entries(routes).reduce((agg, [route, url]) => {
        if(typeof url === "function") {
            // here's the function chaining
            agg[route] = (...args) => base + url.apply(null, args);
        } else if(typeof url === "object") {
            agg[route] = include(base, url);
        }
        return agg;
    }, {});
}

Test code:

const routes = include('/foo', {
    root: () => '',
    bar: include('/bar', {
        baz: () => '/baz',
        withParam: (param = ':param') => "/"+param
    })
});

console.log(routes.root()) // result: "/foo" (calls 1 function)
console.log(routes.bar.baz()) // result: "/foo/bar/baz" (calls 2 chained functions)
console.log(routes.bar.withParam()) // result: "/foo/bar/:param" (calls 2 chained functions)
console.log(routes.bar.withParam("123")) // result: "/foo/bar/123" (calls 2 chained functions)

In terms of React Router it works like this:

<Route path={routes.bar.withParam()} />
<Link to={routes.bar.withParams("123")} />

Maybe the chained functions can be optimized away somehow. But so far it works great!

benneq avatar Dec 11 '18 14:12 benneq

Side note - it's beginning to get way more complicated...

almaron avatar Dec 28 '18 17:12 almaron