How it works
A reasonably deep dive into how found does what it does.
An example
Consider the following wireframe of a web app with side navigation and detail area. The user can navigate to sections (such as "products") and see a list of products. Clicking further on a product navigates to a nested detail view with tab navigation for that specific product.
With Found we can represent this UI with the following route tree and config:
<Route path="/" Component={AppPage}>
<Route path="customers">
<Route Component={CustomersIndexPage} />
<Route path=":customerId" Component={CustomerPage} />
</Route>
<Route path="products">
<Route Component={ProductsIndexPage} getData={fetchProducts} />
<Route path="create" Component={ProductCreatePage} />
<Route
path=":productId"
Component={ProductPage}
getData={fetchProduct}
>
<Route
path="edit"
Component={ProductEditPage}
getData={fetchProductById}
/>
<Route
path="history"
Component={ProductHistoryPage}
getData={fetchProductHistory}
/>
</Route>
</Route>
<Route path="settings" Component={SettingsPage} />
</Route>
Which can also be illustrated via the following tree graph:
Let's consider a single URL and break down how found determines what UI to show and when.
If we want to navigate to a Product's history page we would use the
following URL: /products/1/history
. Clicking on a <Link>
, calling
router.push('/products/1/history')
, or updating the browser URL bar triggers a new "match" resolution.
Matching
The first step in routing is to produce a match
. A match is created by a class called the Matcher
,
which is generally an implementation detail of Found. Simply put, the Matcher takes a URL and produces a
set of route objects that correspond to that URL.
This is accomplished by decomposing the URL into path segments and "matching" them with nodes in our route tree. Illustrated here is a match to our Product history page.
If the matcher is able to map the entire URL to a set of Routes, the matching succeeds and a match object is produced (simplified a bit here):
Match {
location: {
pathname: '/products/1/history',
query: {},
state: null,
},
params: { projectId: '1' },
routes: [
{ path: '/', Component: AppPage, },
{ path: 'products' },
{ path: ':projectId', Component: ProductPage, getData: fetchProductById },
{ path: 'history', Component: ProductHistoryPage, getData: fetchProductHistory },
]
}
matching can only complete at "leaf" nodes in the route tree. This means that
routes that could be leaf or branch nodes (such as /products
) need to include an
"index" route in order to match sucessfully. An index route is a route with a Component
and no path
, as seen above in the route config.
Resolution
The next part in the process is "resolving". The router Resolver
is responsible for mapping the
matched routes to a set of React element
s in order to render the UI for the URL.
To accomplish this it may need to fetch components or server data asynchronously, as specified by the route.
Data fetching and code splitting
Each route in the matched set may specify a getData
property to fetch data necessary to
render its Component
. The Resolver calls each and collects the returned Promise
s which are
all allowed to resolve in parallel--unless a route is deferred
, in which case the promises are split into batches
based on the route hierarchy.
const routeData = await Promise.all(
matchedRoutes.map((route: Route) => {
return route.getData ? route.getData(match) : undefined;
});
)
Similarly Route components can be asynchronously loaded by specifing getComponent
instead
of Component
. Components are also loaded in parallel and awaited before resolving to a set of elements.
const routeComponents = await Promise.all(
matchedRoutes.map((route: Route) => {
return route.getComponent ? route.getComponent(match) : route.Component;
});
)
Element construction
After data and components are resolved, each route is constructed into an element that will be rendered by the Router. Consider how our matched routes relate to the proposed UI:
Each route is ordered by its depth in the route tree and is responsible for rendering itself as well as any nested routes it may contain. Before we can compose the UI together we need to construct its individual pieces. This is done by combining our fetched components and data into elements:
const routeComponents = [
AppPage,
null,
ProductPage,
ProductHistoryPage,
];
const routeData = [null, null, {}, {}];
routes.map((route, index) => {
const Component = routeComponents[index];
const data = routeData[index];
return Component ? <Component data={data} /> : null;
});
Finally this array is folded together by the Router into:
<AppPage>
<ProductPage>
<ProductHistoryPage />
</ProductPage>
</AppPage>
Loading states
One point that was glossed over is how routes can control their own loading UI
while data and components are being fetched. To handle these states the Resolver
actually produces multiple
arrays of elements. While waiting for components and data to be fetched it calls each route.render
,
with the intermediary state. Once the async values are resolved each route.render
is called again
with the final values.
This gives the route total control over how it handles loading fallbacks for itself and child routes. A Route
can explicitly render nothing (null
), a loading spinner, a skeleton UI, or whatever else it wants while
it waits for data.
routes = [
// ...
{
path: ":productId",
getComponent: () =>
import("./components/ProductPage").then((m) => m.default),
getData: fetchProductById,
render({ props, Component }) {
// if Component is not present, it is still being fetched
if (!Component) {
return <Spinner />;
}
// props is the return value of `getData`
// If it's not present, data are still loading
if (!props) {
<Component showSkeleton />;
}
// Otherwise render the component with its props
return <Component {...props} />;
},
},
];
null
and undefined
mean different things as a return value of router.render
!
If you return null
the router will render nothing for that element, whereas undefined
it treated as a special value that means "I can't render yet", which tells the router to continue
to show the existing UI until the route is ready.
This flexibility allows for easy implementation of many different loading UI patterns!
Customizing
Nearly all of the behavior covered here is customizable and extensible, making found ideal for all sorts of of React applications. Checkout our examples and extensions for more ideas.