Ink Integration - greydragon888/real-router GitHub Wiki

Ink Integration

@real-router/react/ink โ€” subpath of the React adapter for building terminal UIs with Ink v7+. Same hooks as the DOM adapter, plus two terminal-specific primitives: InkRouterProvider and InkLink.

npm install @real-router/react @real-router/core @real-router/memory-plugin ink

Peer dependencies: react >= 19.2.0 and ink >= 7.0.0 (Ink v7 itself pins React 19.2+). ink is an optional peer โ€” DOM consumers of @real-router/react are not prompted to install it.


Why a Terminal Adapter?

The official Ink routing recipe (vadimdemedes/ink#874, merged Feb 2026) recommends pairing React Router's MemoryRouter with hand-rolled useInput / useNavigate for every menu item. There is no <Link> equivalent because React Router renders HTML anchors, which terminals cannot process.

@real-router/react/ink ships that recipe packaged:

  • InkLink โ€” focus-aware text link. Joins Ink's focus ring via useFocus, listens for Enter via useInput, renders colored <Text> reflecting focus + active state. No per-menu-item boilerplate.
  • InkRouterProvider โ€” drop-in provider with no DOM dependencies and no aria-live announcer.
  • @real-router/memory-plugin โ€” replaces MemoryRouter. Pure JS history stack, no window.history.
  • All the shared hooks โ€” useRouter, useRoute, useRouteNode, useNavigator, useRouteUtils, useRouterTransition. Identical to the DOM adapter.

Quick Start

import { createRouter } from "@real-router/core";
import { memoryPluginFactory } from "@real-router/memory-plugin";
import {
  InkLink,
  InkRouterProvider,
  useRouteNode,
} from "@real-router/react/ink";
import { Box, Text, render, useApp, useInput } from "ink";

const router = createRouter(
  [
    { name: "home", path: "/" },
    {
      name: "users",
      path: "/users",
      children: [{ name: "view", path: "/:id" }],
    },
    { name: "about", path: "/about" },
  ],
  { defaultRoute: "home", allowNotFound: true },
);

router.usePlugin(memoryPluginFactory());
await router.start("/");

const Menu = () => (
  <Box marginTop={1} columnGap={2}>
    <InkLink routeName="home" focusColor="cyan" activeColor="green" autoFocus>
      [ Home ]
    </InkLink>
    <InkLink routeName="users" focusColor="cyan" activeColor="green">
      [ Users ]
    </InkLink>
    <InkLink routeName="about" focusColor="cyan" activeColor="green">
      [ About ]
    </InkLink>
  </Box>
);

const Body = () => {
  const { route } = useRouteNode("");
  const top = route?.name.split(".")[0];

  switch (top) {
    case "home":
      return <Text>Home</Text>;
    case "users":
      return <Text>Users</Text>;
    case "about":
      return <Text>About</Text>;
    default:
      return <Text>Unknown: {top ?? "โ€”"}</Text>;
  }
};

const App = () => {
  const { exit } = useApp();
  useInput((input, key) => {
    if (input === "q" || key.escape) exit();
  });

  return (
    <Box flexDirection="column" paddingX={1}>
      <Text bold>real-router + Ink demo</Text>
      <Text dimColor>Tab to move focus, Enter to navigate, q to quit.</Text>
      <Menu />
      <Body />
    </Box>
  );
};

render(
  <InkRouterProvider router={router}>
    <App />
  </InkRouterProvider>,
);

A full runnable app lives at examples/react/ink-demo.


What's Exported

import {
  // Components
  InkRouterProvider,
  InkLink,
  RouterErrorBoundary,

  // Hooks (shared with DOM adapter)
  useRouter,
  useRoute,
  useRouteNode,
  useNavigator,
  useRouteUtils,
  useRouterTransition,

  // Types
  InkLinkProps,
  InkRouterProviderProps,
  RouterErrorBoundaryProps,
  Navigator,
  RouterTransitionSnapshot,
} from "@real-router/react/ink";

What is not exported from /ink (and why):

Missing export Reason
Link DOM-only โ€” renders <a> and filters MouseEvent<HTMLAnchorElement>. Use InkLink.
RouteView <Activity> behavior in terminal UIs is untested. Compose routes with useRouteNode("") + switch.
announceNavigation InkRouterProvider does not forward it; createRouteAnnouncer uses document.querySelector + rAF.
scrollRestoration DOM-only โ€” createScrollRestoration reads window.scrollY / history.scrollRestoration. No terminal equivalent. InkRouterProvider does not forward this prop.

<InkRouterProvider>

Root provider for Ink apps. Thin wrapper over the shared RouterProvider that omits the announceNavigation prop โ€” the DOM route announcer has no terminal equivalent.

Prop Type Required Description
router Router Yes Router instance from @real-router/core
children ReactNode Yes Ink component tree
<InkRouterProvider router={router}>
  <App />
</InkRouterProvider>

Under the hood InkRouterProvider reuses the same three-context architecture (RouterContext, NavigatorContext, RouteContext) as the DOM provider โ€” all shared hooks work unchanged.


<InkLink>

Focusable text link. Integrates with Ink's focus manager and keyboard input:

  • Focus โ€” joins the focus ring via useFocus. Tab/Shift+Tab cycle through InkLinks.
  • Activation โ€” useInput({ isActive: isFocused }) listens for Enter and calls router.navigate(...).
  • Styling โ€” resolves a final color and inverse from three precedence tiers (focus โ†’ active โ†’ base).

Props

Prop Type Default Description
routeName string โ€” Target route name (required)
routeParams Params {} Route parameters
routeOptions NavigationOptions {} Navigation options (e.g. { replace: true })
activeStrict boolean false Exact-match active state (no ancestor match)
ignoreQueryParams boolean true Ignore query params when computing active state
color string โ€” Base <Text> color when neither focused nor active
activeColor string โ€” Color when the route is active
focusColor string โ€” Color when Ink focus is on this link
inverse boolean โ€” Base inverse-video
activeInverse boolean โ€” Inverse-video when active
focusInverse boolean โ€” Inverse-video when focused
id string โ€” Passed to useFocus({ id }) for explicit focus targeting
autoFocus boolean โ€” Passed to useFocus({ autoFocus }) โ€” first link gets initial focus
onSelect () => void โ€” Fires on Enter before router.navigate() (telemetry, local UI reactions)
children ReactNode โ€” Link content โ€” typically a string, <Text>, or composed <Box>

Styling Precedence

For each style dimension (color, inverse) the resolution is:

focus     focusColor ?? activeColor ?? color
active    activeColor ?? color
default   color

Same rule for inverse / activeInverse / focusInverse. This lets you specify only what differs โ€” e.g. activeColor="green" alone gives green on active and Ink's default everywhere else.

Keyboard Contract

  • Tab / Shift+Tab โ€” moves focus between InkLinks (Ink focus ring).
  • Enter โ€” calls onSelect?.() then router.navigate(routeName, routeParams, routeOptions). Errors are swallowed (fire-and-forget); use RouterErrorBoundary or call router.navigate() directly for per-click handling.
  • Everything else โ€” passes through to your own useInput handlers.

Example

<InkLink
  routeName="users.view"
  routeParams={{ id: "alice" }}
  focusColor="cyan"
  activeColor="green"
  activeInverse
  id="menu-user-alice"
  onSelect={() => analytics.track("menu_open", { id: "alice" })}
>
  {"Alice"}
</InkLink>

Memoization

InkLink is wrapped in memo() with a custom areInkLinkPropsEqual comparator โ€” identical strategy to the DOM Link:

  • === for primitives (routeName, activeStrict, ignoreQueryParams, colors, inverse flags, id, autoFocus, onSelect, children).
  • shallowEqual (Object.is per key, order-insensitive) for routeParams and routeOptions.

Inline <InkLink routeParams={{ id: "alice" }}> is cheap. Nested objects inside params are not deep-compared โ€” stabilize with useMemo if necessary (the standard React pattern).


Composing Routes Without RouteView

RouteView is absent from /ink. Use useRouteNode("") at the root and switch on route.name or its top segment:

const Body = () => {
  const { route } = useRouteNode("");
  const top = route?.name.split(".")[0];

  switch (top) {
    case "home":
      return <HomePage />;
    case "users":
      return <UsersPage />;
    case "about":
      return <AboutPage />;
    default:
      return <Text>Unknown route: {top ?? "โ€”"}</Text>;
  }
};

Nested routes follow the same pattern per subtree:

const UsersPage = () => {
  const { route } = useRouteNode("users");
  const id = route?.params.id;

  if (route?.name === "users.view" && typeof id === "string") {
    return <UserCard id={id} />;
  }

  return <UserList />;
};

Because useRouteNode subscribes to a specific node, UsersPage does not re-render on top-level navigations like home โ†” about โ€” only when the users.* subtree enters/leaves or its params change.


Imperative Navigation and Hotkeys

useNavigator() returns the stable navigator reference. Combine it with Ink's useInput for hotkeys:

const UserList = () => {
  const navigator = useNavigator();
  const router = useRouter();
  const { route } = useRouteNode("users");
  const selectedId = route?.params.id;

  useInput((_input, key) => {
    if (key.backspace && selectedId !== undefined) {
      router.back(); // history-only method provided by the memory plugin
      return;
    }
    if (key.return) {
      navigator.navigate("users.view", { id: "alice" }).catch(() => {});
    }
  });

  return /* ... */;
};

navigator.navigate() returns a Promise<State> โ€” you can await it for per-click error handling, or call .catch(() => {}) for fire-and-forget like InkLink does.


History and Back/Forward

Browser history APIs don't exist in a terminal. Use @real-router/memory-plugin โ€” it provides router.back(), router.forward(), and router.go(n) over a pure in-memory stack.

import { memoryPluginFactory } from "@real-router/memory-plugin";

router.usePlugin(memoryPluginFactory({ maxHistoryLength: 100 }));
await router.start("/");

See the memory-plugin page for full options.


Error Handling

RouterErrorBoundary works identically to the DOM adapter โ€” it subscribes to createDismissableError(router) with no DOM assumptions. Render a fallback <Box> + <Text> alongside your main tree:

import { RouterErrorBoundary } from "@real-router/react/ink";
import { Box, Text } from "ink";

<RouterErrorBoundary
  fallback={(error, resetError) => (
    <Box borderStyle="round" borderColor="red" paddingX={1}>
      <Text color="red">
        {error.code}: {error.message}
      </Text>
      <Text dimColor>Press "r" to dismiss.</Text>
    </Box>
  )}
  onError={(error) => log(`navigation error: ${error.code}`)}
>
  <App />
</RouterErrorBoundary>;

Auto-resets on the next successful transition. resetError() is also available via the fallback render-prop โ€” wire it to a key binding with useInput if you want manual dismissal.


Constraints (at a glance)

Constraint Detail
React version 19.2+ (Ink v7 requires it โ€” there is no /legacy/ink)
Ink version 7.0.0+ (optional peer โ€” not installed unless you use /ink)
No <Link> DOM-only primitive; use InkLink
No <RouteView> <Activity> in terminal UIs is untested; use useRouteNode("") + switch
No announceNavigation createRouteAnnouncer depends on document.querySelector and requestAnimationFrame
History API Unavailable in terminals โ€” use @real-router/memory-plugin
ignoreQueryParams default true (same as DOM Link)
activeClassName โ†’ activeColor/etc. DOM styling props replaced by Ink <Text> props
onClick โ†’ onSelect No mouse events; activation is Enter-while-focused

Testing

Ink's ink-testing-library renders to a string buffer. Enable ANSI colors in the non-TTY test stdout so color assertions work:

// tests/setup.ts
process.env.FORCE_COLOR = "3";

Then assert against lastFrame() output. Because vitest stdout is not a TTY, Ink writes ANSI escape sequences literally โ€” which is what FORCE_COLOR=3 forces.

The full Ink test suite lives alongside the DOM tests in packages/react/tests/functional/.


Related Packages

Package Role
@real-router/core Router engine (required)
@real-router/memory-plugin History stack for terminal/SSR/RN environments (required)
@real-router/react Host package โ€” /ink is one of its three entry points

See Also

โš ๏ธ **GitHub.com Fallback** โš ๏ธ