Skip to content

πŸ›£ A React library to handle navigation in your WebApp. Built with simple components and React Hooks so your code is cleaner.

License

Notifications You must be signed in to change notification settings

franciscop/crossroad

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Crossroad npm install crossroad test badge gzip size

A React library to handle navigation in your WebApp. Built with simple components and React Hooks so you write cleaner code:

  • <Router>, <Switch> and <Route> inspired by React Router so it's easy to get started.
  • Very useful hooks like useUrl, useQuery, etc. Follow the rules of hooks.
  • Links are plain <a> instead of custom components. Read more.
  • The <Route> path is exact by default and can match query parameters.
  • It's just ~1.5kb (min+gzip) instead of the 17kb of React Router(+Dom).
  • Add scrollUp to <Router> o <Route> to automatically scroll up on a route change.

πŸ”— Demo on CodeSandbox

// App.js
import Router, { Switch, Route } from "crossroad";

export default function App() {
  return (
    <Router>
      <nav>
        <a href="/">Home</a>
        <a href="/users">Users</a>
        ...
      </nav>
      <Switch redirect="/">
        <Route path="/" component={Home} />
        <Route path="/users" component={Users} />
        <Route path="/users/:id" component={Profile} />
      </Switch>
    </Router>
  );
}

Getting Started

Create a React project (npx create-react-app demo) and install Crossroad:

npm i crossroad

Then import it on your App.js and define some routes:

import Router, { Switch, Route } from "crossroad";

export default function App() {
  return (
    <Router>
      <Switch>
        <Route path="/" component={Home} />
        <Route path="/users/:id" component={Profile} />
      </Switch>
    </Router>
  );
}

Then let's add some navigation and the actual pages:

import Router, { Switch, Route } from "crossroad";

const Home = () => <main>Home Page</main>;
const Profile = ({ id }) => <main>Hello {id.toUpperCase()}</main>;

export default function App() {
  return (
    <Router>
      <nav>
        <a href="/">Home</a>
        <a href="/users/a">User A</a>
        <a href="/users/b">User B</a>
      </nav>
      <Switch>
        <Route path="/" component={Home} />
        <Route path="/users/:id" component={Profile} />
      </Switch>
    </Router>
  );
}

Now you can start your project and test it by visiting http://localhost:3000/ and http://localhost:3000/login:

npm start

See the more complete working example in this CodeSandbox.

API

The API is composed of these parts:

  • <Router />: the top-level component that should wrap your whole app.
  • <Switch />: renders only the first child that matches the current url.
  • <Route />: filters whether the given component should be rendered or not for the current URL.
  • <a />: a plain HTML link, use it to navigate between pages.
  • useUrl(): a hook that returns the current URL and a setter to update it.
  • usePath(): a hook that returns the current path and a setter to update it.
  • useQuery(): a hook that returns the current query and a setter to update it.
  • useHash(): a hook that returns the current hash and a setter to update it.
  • useParams(): a hook that extracts params form the current path.

Router is the default export, <a> is not exported since it's just the plain link element, and everything else are named exports:

import Router, { Switch, Route, useUrl, usePath } from "crossroad";

<Router />

The top-level component that has to wrap everything else. Internally it's used to handle clicks, history, etc. It's also the default export of the library:

// App.js
import Router from "crossroad";

export default function App() {
  return <Router>... Your normal App code ...</Router>;
}

Add the prop scrollUp to automatically scroll up the browser window when any route changes. In contrast, you could also add it only to a single or multiple <Route>.

Add the prop url to simulate a fake URL instead of the current window.location, useful specially for testing.

You would normally setup this Router straight on your App, along things like Statux's or Redux's Store, error handling, translations, etc.

An example for a simple app:

// App.js
import Router, { Switch, Route } from "crossroad";

import Home from "./pages/Home";
import Dashboard from "./pages/Dashboard";
import Profile from "./pages/Profile";

export default function App() {
  return (
    <Router>
      <Switch>
        <Route path="/" component={Home} />
        <Route path="/dashboard" component={Dashboard} />
        <Route path="/:username" component={Profile} />
      </Switch>
    </Router>
  );
}

<Switch />

A component that will only render the first of its children that matches the current URL. This is very useful to handle 404s, multiple routes matching, etc. For example, if you have a username system like "/:username" but want to have a help page, you can make it work easily with the switch:

// In https://example.com/help, it'll render the Help component only
<Switch>
  <Route path="/help" component={Help} />
  <Route path="/:username" component={User} />
</Switch>

You might want to redirect the user to a specific route (like /notfound) when none of the given routes matches the current URL. You can then use the attribute "redirect":

<Switch redirect="/notfound">
  <Route path="/path1" component={Comp1} />
  <Route path="/path2" component={Comp2} />
  <Route component={NotFound} />
</Switch>

The redirect parameter can be a plain string, an url-like object or a callback that returns any of the previous:

<Switch redirect="/gohere?hello=world"></Switch>
<Switch redirect={{ path: "/gohere", query: { hello: "world" } }}></Switch>
<Switch redirect={() => "/gohere"}></Switch>
<Switch redirect={url => ({ ...url, path: "/gohere" })}></Switch>

Or to keep it in the current route, whatever it is, you can render a component with no path (no path === *):

<Switch>
  <Route path="/path1" component={Comp1} />
  <Route path="/path2" component={Comp2} />
  <Route component={NotFound} />
</Switch>

The <Switch> component only accepts <Route> as its children.

<Route />

This component defines a conditional path that, when strictly matched, renders the given component. Its props are:

  • path: the path to match to the current browser's URL. It can have parameters /:id and a wildcard at the end * to make it a partial route.
  • component: the component that will be rendered if the browser's URL matches the path parameter.
  • render: a function that will be called with the params if the browser's URL matches the path parameter.
  • children: the children to render if the browser's URL matches the path parameter.
  • scrollUp: automatically scroll up the browser window when this route/component/etc is matched.

So for example if the path prop is "/user" and you visit the page "/user", then the component is rendered; it is ignored otherwise:

// In https://example.com/
<Route path="/" component={Home} /> // Rendered
<Route path="/*" component={Any} />  // Rendered
<Route path="/user" component={User} />  // Not rendered
<Route path="/:page" component={Page} />  // Not rendered

// In https://example.com/user/
<Route path="/" component={Home} /> // Not Rendered
<Route path="/*" component={Any} />  // Rendered
<Route path="/user" component={User} />  // Rendered
<Route path="/:page" component={Page} />  // Rendered

When matching a path with a parameter (a part of the url that starts with :) it will be passed as a prop straight to the children:

// In https://example.com/user/abc
const User = ({ id }) => <div>Hello {id}</div>;
const UserList = () => <div>List here</div>;

<Route path="/user/:id" component={User} />;
// <div>Hello abc</div>

<Route path="/user/:id" render={({ id }) => <User id={id} />} />;
// <div>Hello abc</div>

// Avoid when you need the params, since they cannot be passed
<Route path="/user/">
  <UserList />
</Route>;
// <div>List here</div>

NOTE: the parameter is passed straight to the component instead of wrapped like in React Router.

The path can also include a wildcard *, in which case it will perform a partial match of everything before itself. It can only be at the end of the path:

// In https://example.com/user/abc

// All of these match the current route
<Route path="*" component={User} />
<Route path="/*" component={User} />
<Route path="/user/*" component={User} />
<Route path="/user/abc/*" component={User} />
<Route path="/user/:id/*" component={User} />

NOTE: in Crossroad the paths are exact by default, and with the wildcard you can make them partial matches. So the wildcard is the opposite of adding exact to React Router.

It can also match query parameters:

// In /profile?page=settings&filter=abc

// All of these match the current route
<Route path="/profile" component={User} />
<Route path="/profile?page" component={User} />
<Route path="/profile?page=settings" component={User} />
<Route path="/profile/*?page=settings" component={User} />
<Route path="/:id?page=settings" component={User} />
<Route path="/:id/*?page=settings" component={User} />

// These shall not match:
<Route path="/?page" component={User} />  // Wrong path
<Route path="/profile?page2" component={User} />  // Wrong key
<Route path="/profile?page=options" component={User} />  // Wrong value

<a>

Links with Crossroad are just traditional plain <a>. You write the URL and a relative path, and Crossroad handles all the history, routing, etc:

export default () => (
  <nav>
    <a href="/">Home</a>
    <a href="/users">Users</a>
    <a href="/settings">Settings</a>
  </nav>
);

An important concept to understand is where links open, whether it's a react navigation or a browser page change:

  • /: plain paths will navigate within React
  • /?abc=def: queries, hashtags, etc. will also perform a navigation in React
  • https://example.com/: full URLs will trigger a browser page change
  • target="_self": will trigger a browser page change, in the same tab
  • target="_blank": will open a new tab

Some examples:

// In https://example.com/users/25

// React navigation:
<a href="/">Home</a>

// React navigation:
<a href="/users?filter=new">New users</a>

// Page refresh (since it's a full URL)
<a href="https://google.com/">Google it</a>

// Page refresh (a full URL, even in the same domain)
<a href="https://example.com/">Home</a>

// Page refresh (it has a target="_self")
<a href="/update" target="_self">Update</a>

// New tab (it has a target="_blank")
<a href="/terms-of-service" target="_blank">Read terms of service</a>

useUrl()

NOTE: within Crossroad's and for lack of a better name, "URL" refers to the combination of path + search query + hash.

Read and set the full URL:

import { useUrl } from "crossroad";

export default function Login() {
  const [url, setUrl] = useUrl();

  const login = async () => {
    // ... do some stuff ...
    setUrl("/welcome");
  };

  return <Button onClick={login}>Login</Button>;
}

These are the structures of each:

  • url: an object with the properties, it's similar to the native URL:
    • url.path: a string with the current pathname
    • url.query: an object with the keys and values. Example: { q: 'hello' }, { q: 'hello', s: 'world' }.
    • url.hash: the hashtag, without the "#"
  • setUrl(): a setter in the React Hooks style
    • setUrl("/newpath?search=hello"): a shortcut with the string
    • setUrl({ path: '/newpath' }): set the path (and delete anything else if any)
    • setUrl({ path: '/newpath', query: { hello: 'world' } }): update the path and query (and delete the hash if any)
    • setUrl(prev => ...): use the previous url (object)

useUrl() is powerful enough for all of your needs, but you might still be interested in other hooks to simplify situations where you do e.g. heavy query manipulation with useQuery.

url

The resulting url is an object containing each of the parts of the URL:

// In /whatever?filter=hello#world
const [url, setUrl] = useUrl();
console.log(url.path); // /whatever
console.log(url.query); // { filter: hello }
console.log(url.hash); // world

It is memoized, so that if the url doesn't change then the object will remain the same. The same of course applies to the subelements like url.path. It will however change when the url changes, so you want to put it in your dependencies as usual:

// You can put the whole thing if you want to listen to
// ANY change on the url
useEffect(() => {
  // ...
}, [url]);

// Or only a part of it. This is useful becase it WON'T trigger
// when the query or hashtag change
useEffect(() => {
  // ...
}, [url.path]);

Setter

The setter can be invoked directly, or with a callback:

const [url, setUrl] = useUrl();

// [Shorthand] Redirect to home with a hashtag
setUrl("/#firsttime");

// Same as above, but specifying the parts
setUrl({ path: "/", hash: "firsttime" });

// Keep everything the same except the path
setUrl({ ...url, path: "/" });

// Set a full search query
setUrl({ ...url, query: { search: "hello" } });

// Modify only one query param
setUrl({ ...url, query: { ...url.query, safe: "no" } });

The function setUrl is always the same, so it doesn't matter whether you put it as a dependency or not. However the path can be updated and change, so you want to depend on it:

const [url, setUrl] = useUrl();
useEffect(() => {
  if (url.path === "/base") {
    setUrl("/base/deeper");
  }
}, [url.path, setUrl]);

If you update the url with the current url, it won't trigger a rerender. So the above can also be written as this, removing all dependencies:

const [url, setUrl] = useUrl();
useEffect(() => {
  setUrl((old) => {
    if (old.path === "/base") return "/base/deeper";
    return old;
  });
}, []);

New history entry

By default setUrl() will create a new entry in the browser history. If you want to instead replace the current url you can pass a second parameter with { mode: 'replace' }:

setUrl("/newurl"); // Default: "push"
setUrl("/newurl", { mode: "replace" });
  • push (default): creates a new entry in the history. E.g. if you navigate /a => /b =(push)> /c and then click on the back button, the browser will go back to /b. This is because /b and /c are both independent entries in your history.
  • replace: creates a new entry in the history. E.g. if you navigate /a => /b =(replace)> /c and then click on the back button, it'll go back to /a. This is because /c is overwriting /b, instead of adding a new entry.

usePath()

Read and set only the path(name) part of the URL:

const Login = () => {
  const [path, setPath] = usePath();

  const login = async () => {
    // ...
    setPath("/welcome");
  };

  return <Button onClick={login}>Login</Button>;
};

The path is always a string equivalent to window.location.pathname. Why not use window.location.pathname then? Because usePath() is a hook that will trigger a re-render when the path changes!

Note: setPath only modifies the path(name) and keeps the search query and hash the same, so if you want to modify the full URL you should instead utilize useUrl() and setUrl('/welcome')

Setter

The setter can be invoked directly, or with a callback:

setPath("/newpath");
setPath((oldPath) => "/newpath");

The function setPath is always the same, so it doesn't matter whether you put it as a dependency or not. However the path can be updated, so you might want to put that:

const [path, setPath] = usePath();
useEffect(() => {
  if (path === "/base") {
    setPath("/base/deeper");
  }
}, [path, setPath]);

If you update the path with the current path, it won't trigger a rerender. So the above can also be written as this, removing all dependencies:

const [path, setPath] = usePath();
useEffect(() => {
  setPath((old) => {
    if (old === "/base") return "/base/deeper";
    return old;
  });
}, []);

New history entry

By default setPath() will create a new entry in the browser history. If you want to instead replace the current url you can pass a second parameter with { mode: 'replace' }:

setPath("/newpath"); // Default: "push"
setPath("/newpath", { mode: "replace" });
  • push (default): creates a new entry in the history. E.g. if you navigate /a => /b =(push)> /c and then click on the back button, the browser will go back to /b. This is because /b and /b?q=c are both independent entries in your history.
  • replace: creates a new entry in the history. E.g. if you navigate /a => /b =(replace)> /c and then click on the back button, it'll go back to /a. This is because /c is overwriting /b, instead of adding a new entry.

useQuery()

Read and set only the search query parameters from the URL:

import { useQuery } from "crossroad";

export default function SearchInput() {
  // In /users?search=
  const [query, setQuery] = useQuery();
  // [{ search: "" }, fn]

  // Goes to /users?search={value}
  const onChange = (e) => setQuery({ search: e.target.value });

  return <input value={query.search} onChange={onChange} />;
}

If you pass a key, it can read and modify that parameter while keeping the others the same. This is specially useful in e.g. a search form:

// In /users?search=name&filter=new
const [search, setSearch] = useQuery("search");
// 'name'

setSearch("myname");
// Goto /users?search=myname&filter=new

When you update it, it will clean any parameter not passed, so make sure to pass the old ones if you want to keep them or a new object if you want to scrub them:

// In /users?search=name&filter=new
const [query, setQuery] = useQuery();

setQuery({ search: "myname" });
// Goto /users?search=myname  (removes the filter)

setQuery({ ...query, search: "myname" });
// Goto /users?search=myname&filter=new

setQuery((prev) => ({ ...prev, search: "myname" }));
// Goto /users?search=myname&filter=new

setQuery only modifies the query string part of the URL, keeping the path and hash the same as they were previously.

When you set a search query to null it will be removed from the URL. However, empty strings "", zero 0 or boolean false are not removed. So if you want falsy values to also remove the parameter in the URL, please do this:

const [myname, setMyname] = useQuery("myname");

// ...

setMyname(newName || null);

If you are using react-query and already have a bunch of useQuery() in your code and prefer to use other name, you can rename this method when importing it:

import { useQuery as useSearch } from 'crossroad';
...

New history entry

By default setQuery() will create a new entry in the browser history. If you want to instead replace the current entry, so that the "Back" button goes to the previous page, you can pass a second parameter with { mode: 'replace' }:

setQuery({ search: "abc" }); // Default: "push"
setQuery({ search: "abc" }, { mode: "replace" });
  • push (default): creates a new entry in the history. E.g. if you navigate /a => /b =(push)> /b?q=c and then click on the back button, the browser will go back to /b. This is because /b and /b?q=c are both independent entries in your history.
  • replace: creates a new entry in the history. E.g. if you navigate /a => /b =(replace)> /b?q=c and then click on the back button, it'll go back to /a. This is because /b?q=c is overwriting /b, instead of adding a new entry.

useHash()

Read and set only the hash part of the URL (without the "#"):

// In /login#welcome
const [hash, setHash] = useHash();
// welcome

setHash("bye");
// Goto /login#bye

By default setHash() will create a new entry in the browser history. If you want to instead replace the current entry you can pass a second parameter with { mode: 'replace' }:

setHash("newhash", { mode: "replace" });

If you want to remove the hash, pass a null or undefined to the setter.

New history entry

By default setHash() will create a new entry in the browser history. If you want to instead replace the current entry, so that the "Back" button goes to the previous page, you can pass a second parameter with { mode: 'replace' }:

setHash("newhash"); // Default: "push"
setHash("newhash", { mode: "replace" });
  • push (default): creates a new entry in the history. E.g. if you navigate /a => /b =(push)> /b#c and then click on the back button, the browser will go back to /b. This is because /b and /b?q=c are both independent entries in your history.
  • replace: creates a new entry in the history. E.g. if you navigate /a => /b =(replace)> /b#c and then click on the back button, it'll go back to /a. This is because /b#c is overwriting /b, instead of adding a new entry.

useParams()

Parse the current URL against the given reference:

// In /users/2
const params = useParams("/users/:id");
// { id: '2' }

Note: this returns a plain object, not a [value, setter] array

It's not this method responsibility to match the url, just to attempt to parse it, so if there's no good match it'll just return an empty object (use a <Route /> for path matching):

// In /pages/settings
const params = useParams("/users/:id");
// {}

Examples

Static routes

Let's see a traditional company website, where you have a homepage, some specific pages and a PDF:

Codesandbox example

crossroad-static-routes.mp4
// App.js
import Router, { Switch, Route } from "crossroad";

import Nav from "./Nav";
import Pages from "./Pages";

export default function App() {
  return (
    <Router>
      <Nav />
      <Switch redirect="/">
        <Route path="/" component={Pages.Home} />
        <Route path="/about" component={Pages.AboutUs} />
        <Route path="/product1" component={Pages.MainProduct} />
        <Route path="/product2" component={Pages.AnotherProduct} />
      </Switch>
    </Router>
  );
}

Now that we have our routing, and for simplicity sake, let's say all of our navigation links are inside a <nav>:

export default function Nav() {
  return (
    <nav>
      <a href="/">Home</a>
      <a href="/about">About Us</a>
      <a href="/product1">Product 1</a>
      <a href="/product2">Product 2</a>
      <a href="/license.pdf" target="_blank">
        License
      </a>
    </nav>
  );
}

That's it, in the Codesandbox we added some filler for the pages, but that's the basic structure of how to make it work.

Vanity URLs

These refer to the websites where your username is straight after the domain, like Twitter (https://twitter.com/fpresencia). Of course Twitter has other pages besides the username, so how can we emulate loading the page e.g. /explore in this case?

The best way is to first define the known, company pages and then use the wildcard for the usernames. This must be inside a <Switch>, otherwise multiple will be rendered:

<Switch>
  <!-- Company pages -->
  <Route path="/" component={Home} />
  <Route path="/home" component={Home} />
  <Route path="/explore" component={Explore} />

  <!-- Username page -->
  <Route path="/:username" component={Profile} />
</Switch>

This way we can handle the username inside Profile, and the other company-specific pages will load as expected. To work with the parameter, you can either use the props passed form the component or with the hook useParams():

// The parameters are passed straight to the component:
function Profile({ username }) {
  return <div>Hello {username}</div>;
}

// or

// Use a hook to access the parameters:
function Profile() {
  const { username } = useParams("/:username");
  //
  return <div>Hello {username}</div>;
}

// or

// The path is defined as `/:username` already in <Route>, we can reuse that:
function Profile() {
  const { username } = useParams();
  //
  return <div>Hello {username}</div>;
}

In the end of the day we recommend picking one style and following it. For simple applications we recommend the first one, where you receive the parameters straight in the props. For more complex applications, including those with deep nesting, we recommend the hook with the named parameter (explicit is more clear than implicit).

Search page

There are many ways to store the state to be able to visit later; localStorage, through API calls to the backend, cookies, etc. One place that people don't think often is the URL itself.

Thanks to useQuery(), it's trivial to use the search query for storing variables. Let's say you are looking for trips in a specific location, on a budget:

Codesandbox demo

crossroad-search-query.mp4
import { useQuery } from "crossroad";

export default function SearchForm() {
  const [place, setPlace] = useQuery("place");
  const [max, setMax] = useQuery("max");

  return (
    <form>
      <TextInput value={place} onChange={setPlace} ... />
      <NumberInput value={max} onChange={setMax} ... />
    </form>
  );
}

In here we can see that we are treating the output of useQuery in the same way that we'd treat the output of useState(). This is on purpose and it makes things a lot easier for your application to work.

Query routing

Some times you prefer the current page to be defined by the query, instead of by the pathname. This might be true for subpages, for tabs, or for other things depending on your app. With Crossroad it's easy to manage:

Codesandbox

crossroad-query-navigation.mp4
<Switch redirect="/?page=home">
  <Route path="/?page=home" component={Tabs.Home} />
  <Route path="/?page=product" component={Tabs.Product} />
  <Route path="/?page=about" component={Tabs.About} />
</Switch>

With the code above, it will match the given component when the path is exactly "/" and the query parameter is the given one. If no one is matched, then it'll redirect you to /?page=home, the main page.

You can also use this for subpages, say if you were in a Dashboard:

<Switch redirect="/dashboard?tab=home">
  <Route path="/dashboard?tab=home" component={Tabs.Home} />
  <Route path="/dashboard?tab=product" component={Tabs.Product} />
  <Route path="/dashboard?tab=about" component={Tabs.About} />
</Switch>

Not found

We have already seen in different examples how to do simple redirects with a single <Switch redirect="">, so now let's create a page for whenever the switch is not found:

<Switch>
  <Route path="/" component={Home} />
  <Route path="/users" component={Users} />

  {/* Not found page */}
  <Route component={NotFound} />
</Switch>

This page will maintain the url in the browser, but render the NotFound component. Notice how we didn't write any path="", omitting the path is the same as writing it as path="*", which will catch everything.

So the way this Switch works here, it will try to match the URL against "/", then against "/users", and if it's none of those it'll match it against "*" (since that's always a match) and render the NotFound component.

We can also have different not found pages. Let's say we have a specific "documentation page not found" with helpful documentation links and a general one for the rest of the website, we can manage them this way then:

<Switch>
  <Route path="/" component={Home} />
  <Route path="/docs/abc" component={DocsAbc} />
  <Route path="/docs/def" component={DocsDef} />

  {/* Not found page only for the docs */}
  <Route path="/docs/*" component={NotFoundDocs} />

  {/* Not found page only for everything else */}
  <Route component={NotFound} />
</Switch>

In this case the order matters, because the generic NotFound will be matched with any route (since it's "*"), so we need to match first the docs that is not found and then, even if that fails (e.g. on the path /hello) we can render the generic NotFound component.

Github hosting

NOTE: this is a bad idea for SEO, but if that doesn't matter much for you go ahead and host your webapp in Github Pages

Github pages is a bit particular in that as of this writing it does not allow for a generic redirect like most other static website servers, so we need to do a workaround with the 404.html page.

This is because any of your visitors landing on https://example.com/ will see the proper website (since that'll be directed to docs/index.html), but when the user lands on other paths like https://example.com/info it'll not find docs/info.html and thus render 404.html.

So let's save the url and setup a redirect in 404.html:

<!-- 404.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Redirecting...</title>
  </head>
  <body>
    <script>
      const url = JSON.stringify(location.pathname + location.search);
      localStorage.url = url;
      location.replace("/");
    </script>
  </body>
</html>

Then in your index.html, or in almost anywhere else, you can overwrite the URL:

if (localStorage.url) {
  history.replaceState({}, null, JSON.decode(localStorage.url));
  delete localStorage.url;
}

Testing routes

When testing a route, we can do it mainly in two different ways. The recommended one in general is that you pass a url prop straight into your <Router> component, which will force the Router to behave like the browser is in that route.

Let's see first a very simple App example, noting that for this case we are passing the url from App to Router:

// App.js
import Router, { Switch, Route } from "crossroad";

// Imagine these are your apps and components:
const Home = () => <div>Home</div>;
const Users = () => <div>Users</div>;
const NotFound = () => <div>Website not found</div>;

export default function App({ url }) {
  return (
    <Router url={url}>
      <Switch>
        <Route path="/" component={Home} />
        <Route path="/users" component={Users} />
        <Route component={NotFound} />
      </Switch>
    </Router>
  );
}

How does it work? The url prop will be undefined when a user loads the app (since we don't add it to index.js), so it is only being written for testing. On the users' browser, since it's undefinde Crossroad will use window.location.href instead.

// App.test.js
import React from "react";
import $ from "react-test";

import App from "./App";

describe("use the url prop", () => {
  it("renders the home component on /", () => {
    const $home = $(<App url="/" />);
    expect($home.text()).toBe("Home");
  });

  it("renders the user list on /users", () => {
    const $home = $(<App url="/users" />);
    expect($home.text()).toBe("Users");
  });

  it("renders not found when in another route", () => {
    const $home = $(<App url="/bla" />);
    expect($home.text()).toContain("not found");
  });
});

This method is the simplest to get started, but some people don't like having to add code to the production website only for the testing environment. That's all fine, there's another way that is a bit harder to setup but it's also more accurate to the browser's real behavior.

Mock window.location

The previous method has a big limitation: it doesn't allow you to navigate within your app for a test, since it's always forcing the same url. To avoid this and be able to test better the real-world behavior, use this method.

When you are running Jest, it creates a fake window already, so you can plug into that to mock the behavior for the duration of the test. Doing it with a React component makes it even smoother:

// Mock.js
import React, { useEffect } from "react";

export default function Mock({ url, children }) {
  const href = "http://localhost:3000" + url;
  const oldLocation = { value: window.location };
  delete global.window.location;
  Object.defineProperty(global.window, "location", {
    value: new URL(href),
    configurable: true,
  });

  // Undo the setup when the component unmounts
  useEffect(() => {
    return () => Object.defineProperty(window, "location", oldLocation);
  }, []);
  return <div>{children}</div>;
}

With this Mock component, then you can wrap your normal application into working with routes:

import React from "react";
import $ from "react-test";

import App from "./App";
import Mock from "./Mock";

describe("use the Mock component", () => {
  it("renders the home component on /", () => {
    const $home = $(
      <Mock url="/">
        <App />
      </Mock>
    );
    expect($home.text()).toBe("Home");
  });

  it("renders the user list on /users", () => {
    const $home = $(
      <Mock url="/users">
        <App />
      </Mock>
    );
    expect($home.text()).toBe("Users");
  });

  it("renders not found when in another route", () => {
    const $home = $(
      <Mock url="/bla">
        <App />
      </Mock>
    );
    expect($home.text()).toContain("not found");
  });
});

Server Side Render

Crossroad has been tested with these libraries/frameworks for SSR:

  • βœ… Razzle: it works adding a bit of config; Razzle bundles React Router Dom by default, so you need to install Crossroad, remove React Router Dom and add the code mentioned below.
  • ⚠️ Next.js: it works, but is generally not needed since Next.js include its own router and file-based routing.
  • ❌ Babel-Node: BabelNode doesn't support ECMAScript modules (ESM), but you are also not supposed to use babel-node for production anyway so this is not a real framework for SSR.
  • Others? I couldn't find many other ways that people are running SSR that I could test.

For Razzle (based on these docs FaQ):

// razzle.config.js
module.exports = {
  modifyWebpackOptions({ options: { webpackOptions } }) {
    webpackOptions.notNodeExternalResMatch = (req) => /crossroad/.test(req);
    webpackOptions.babelRule.include.push(/crossroad/);
    return webpackOptions;
  },
};

When working on the server, and similar to how we saw in testing, we can overload the current url:

// An express example
const App = ({ url }) => <Router url={url}>...</Router>;

app.get("/users", (req, res) => {
  res.render(<App url="/users" />);
});

app.get("/users/:id", (req, res) => {
  // {...} validate the `id` it here!

  res.render(<App url={req.url} />);
});

React Router diff

This part of the documentation tries to explain in detail the differences between Crossroad and React Router (Dom). Crossroad goal is to build a modern Router API from scratch, removing the legacy code and using Hooks natively.

Intuitive API

I've been using React Router for 3-4 years and I still get wrong how to properly import it and have to try 2-3 combinations or reading the docs! I prefer to use intuitive tools that I can learn and get out of the way.

So this is a clear win, with Crossroad you import it like this:

import Router, { Switch, Route, ... } from 'crossroad';

While with React Router, guess the correct one:

import { Switch, Route, ... } from 'react-router';
import Router from 'react-router-dom';


import Router, { Switch, Route, ... } from 'react-router-dom';

import { Switch, Route, ... } from 'react-router';
import { BrowserRouter } from 'react-router-dom';

Tip: none of them are correct!

Remove imperative API

With React Router your component receives the props history. This is no longer needed with Crossroad; instead of handling the history details, we provide a hook useUrl() with the setter setUrl() where you can set the new URL straight away:

import { useUrl } from "crossroad";

export default function LoginButton() {
  const [url, setUrl] = useUrl();
  const login = async (e) => {
    // ...
    setUrl("/welcome");
  };
  return <button onClick={login}>Login</button>;
}

The other hooks, like useQuery(), behave in a similar way so you don't need to be concerned about the history API at all.

Useful Hooks

I've seen in multiple codebases people end up creating a useQuery() hook wrapping useLocation and useHistory to work with query parameters. Fear no more, this and some other useful hooks are there already on Crossroad and you can use them straight away:

// setUrl() is quite flexible:
const [url, setUrl] = useUrl();

setUrl("/#firsttime"); // [Shorthand] Redirect to home with a hashtag
setUrl({ path: "/", hash: "firsttime" }); // Same as above
setUrl({ ...url, path: "/" }); // Keep everything the same except the path
setUrl({ ...url, query: { search: myQuery } }); // Set a full search query
setUrl({ ...url, query: { ...url.query, safe: 0 } }); // Modify only one query param
// In /?search=myname&filter=new

// Manipulate the whole query object
const [query, setQuery] = useQuery();
setQuery({ ...query, search: "myname2" });

// Manipulate _only_ the query parameter "search"
const [search, setSearch] = useQuery("search");
setSearch("myname2");

See a video and a demo of how useful and easy it is to use useQuery():

Codesandbox demo

crossroad-search-query.mp4

Plain Links

To add a link in your application, you use the native <a> element instead of having to import a different component. What's more, this makes links a lot more consistent than in React Router. Some examples:

// Crossroad

// Normal link
<a href="/">Hello</a>

// Open in same page with refresh
<a href="https://example.com/">Hello</a> // http(s): links open with refresh
<a href="/" target="_self">Hello</a>  // self to open with refresh

// Open in new page
<a href="/myfile.pdf" target="_blank">Hello</a>  // Traditional target blank
<a href="https://example.com/" target="_blank">Hello</a>

The same in React Router are like this, note the inconsistencies of some times using <Link> and some times using <a>

// React Router

// Normal link
<Link to="/">Hello</Link>

// Open in same page with refresh
<a href="https://example.com/">Hello</Link>
<a href="/">Hello</Link>

// Open in new page
<a href="https://example.com/" target="_blank">Hello</Link>
<Link to="https://example.com/">Hello</Link>  // Broken

About

πŸ›£ A React library to handle navigation in your WebApp. Built with simple components and React Hooks so your code is cleaner.

Topics

Resources

License

Stars

Watchers

Forks