Navigating & Observing
Navigation and observation are closely linked. Navigation is used to change locations, while observation is used to detect navigation changes and react (e.g. re-render the application).
Navigation#
When you create a router, you pass it a history object. That history object is responsible for tracking all navigation within your application. Navigation can be triggered a variety of ways.
The first type is "external" navigation, meaning it comes from outside of the application. For a website, this would be navigating by entering a URL in the address bar or by pressing the browser's forward/back buttons.
The second type of navigation is "internal", where you use code to navigate to a new location. This is predominantly done by clicking links.
This guide is only going to discuss how to perform internal navigation, since that is the only type that you need to perform with code.
import Browser from "@hickory/browser";
import { curi } from "@curi/router";
import routes from "./routes";
// this history object is responsible for
// tracking navigation
const history = Browser();
const router = curi(history, routes);
Navigation With History#
Locations are stored in what is essentially an array (this varies by history type). An index is used to keep track of which location in the array is the current location.
// array of locations
[
{ pathname: "/one" },
{ pathname: "/two" },
{ pathname: "/three" }
]
// index = 2, current location = { pathname: "/three" }
There are three ways to change locations: popping, pushing, and replacing and two methods for making these changes: history.go()
and history.navigate()
.
Pop#
Popping means that you change the index to another (valid) index in the array. Popping is performed by specifying how many locations forward (positive numbers) or backward (negative numbers) you want to go. When you click a browser's back button, that is essentially popping by negative one.
The history object's go()
function is used for popping between locations.
locations = [
{ pathname: "/one" },
{ pathname: "/two" },
{ pathname: "/three" }
]
index = 2
// current location = { pathname: "/three" }
history.go(-2)
locations = [
{ pathname: "/one" },
{ pathname: "/two" },
{ pathname: "/three" }
]
index = 0
// current location = { pathname: "/one" }
Push#
Pushing adds a new location after the current location in the array. Pushing is destructive because if there were any locations after the current location, they are lost when you push a new location.
The history object's navigate()
method is used for pushing new locations. In order to ensure that a location is pushed, the "PUSH"
argument should be passed to the method call.
locations = [
{ pathname: "/one" },
{ pathname: "/two" },
{ pathname: "/three" }
]
index = 0
// current location = { pathname: "/one" }
history.navigate("/four", "PUSH")
locations = [
{ pathname: "/one" },
{ pathname: "/four" }
]
index = 1
// current location = { pathname: "/four" }
Replace#
Replacing replaces the location at the current index with a new location. When you replace the current location, it has no effect on locations after the current one.
The history object's navigate()
method is used for replacing locations. In order to ensure that a location is replaced, the "REPLACE"
argument should be passed to the method call.
locations = [
{ pathname: "/one" },
{ pathname: "/two" },
{ pathname: "/three" }
]
index = 2
// current location = { pathname: "/three" }
history.navigate("/four", "REPLACE")
locations = [
{ pathname: "/one" },
{ pathname: "/two" },
{ pathname: "/four" }
]
index = 2
// current location = { pathname: "/four" }
Anchor#
The history.navigate()
method has one other way of navigating, which is also its default method. This method is called "ANCHOR"
because it simulates how clicking an anchor in a non-single-page application works.
Anchor navigation is a hybrid of pushing and replacing. If you attempt to navigate to the same location as the current location (same pathname
, query
, and hash
), then the current location will be replaced. If you attempt to navigate to a new location, it will be pushed.
Unless you have a reason to explicitly push/replace, anchor navigation is what you should use for navigation.
locations = [
{ pathname: "/one" },
{ pathname: "/two" },
{ pathname: "/three" }
]
index = 2
history.navigate("/three")
// same location, so nothing changes
locations = [
{ pathname: "/one" },
{ pathname: "/two" },
{ pathname: "/three" }
]
index = 2
history.navigate("/four")
// new location is pushed
locations = [
{ pathname: "/one" },
{ pathname: "/two" },
{ pathname: "/three" },
{ pathname: "/four" }
]
index = 3
Navigation with the Router#
In the above examples, navigation is done using URL pathnames, but one of the principles of Curi is that you shouldn't have to write URLs yourself. To help with this, the router has its own navigate()
method.
router.navigate()
takes an object with the name
of the route to navigate to. If the route (or any of its ancestors) requires params
, they should also be provided through the object.
query
, hash
, and state
properties can also be provided to pass any of those location details.
router.navigate()
does anchor style ("ANCHOR"
) navigation by default, but if you want to do "PUSH"
/"REPLACE"
navigation, you can provide the type with the method
property.
router.navigate({
name: "User",
params: { id: 1423 }
});
// replace the current location with the Login route
router.navigte({
name: "Login",
state: { next: "/profile" }
method: "REPLACE"
});
Detecting Navigation#
The Curi router uses an observer pattern to call registered functions (called response handlers) when there is a new response. The main function for response handlers is to use the new response to render the application, but any other functionality (like logging) can also be performed.
Response Handlers#
When response handlers are called, they are passed an object with three properties: router
, response
, and navigation
. Which objects/properties you use depends on what the response handler is doing.
function responseHandler({
router,
response,
navigation
}) {
// ...
}
Registering Response Handlers#
There are two ways to attach response handlers to the router: router.once()
and router.observe()
. Response handlers registered with router.once()
will only be called one time, while those registered with router.observe()
will be called for every new response.
When you register a response handler using router.observer()
, it will return a function that you can use to stop calling the response handler for new responses. You should rarely need to do this, but it can be useful for memory management if you are adding and removing lots of observers.
// fn will only be called one time
router.once(fn);
// obs will be called for every new response
const stop = router.observer(fn);
Use Cases#
What should you use response handlers for?
Setup#
If any of the routes in an application have resolve
functions, when they match their responses are created asynchronously. When the application first renders, if the router matches an async route, the response isn't immediately ready to use. To deal with this, you can use an observer to render once the initial response is ready.
A setup function only needs to be called one time, so you can register it with router.once()
.
const Router = curiProvider(router);
function setup() {
ReactDOM.render((
<Router>
{({ response }) => <response.body />}
</Router>
), document.getElementById('root'));
}
router.once(setup);
Rendering#
Rendering libraries need to know when there is a new response so that they can re-render the application.
The Curi rendering packages (@curi/react-dom
, @curi/react-native
, @curi/vue
, and @curi/svelte
) setup an observer internally so that they can automatically re-render.
If you are using vanilla JavaScript to render your application or you are writing your own framework implementation, you would use router.observer()
to re-render new responses.
function observer({ response }) {
// let the app know there is a new response
}
router.observer(observer);
Side Effects#
Side effects are observers that are provided to the router at creation instead of by calling router.observe()
. These can be useful for tasks that are not rendering related as well as for tasks that need to be performed after a render has completed.
The @curi/side-effect-title
package provides a side effect that will use response.title
to set the page's document.title
.
With single-page applications, clicking on links wish hashes won't always scroll to the matching element in the page. The @curi/side-effect-scroll
package adds this behavior by scrolling the page to the element that matches the new response's hash (response.location.hash
) after the new response has rendered.
If you need to add logging to your application, you could write your own observer to do this. Your observer can either be added as a side effect when the router is constructed or later using router.observe()
.
function logger({ response }) {
loggingAPI.add(response.location);
}
// as a side-effect
const router = curi(history, routes, {
sideEffects: [{ fn: logger }]
});
// as an observer
router.observe(logger);