Composable Link Component that Works in Any React Meta-Framework

jo
joshk23 weeks ago

Write once, use everywhere

One link, multiple platforms. Bit's universal link component, can be used with any React meta-framework.

To grasp the great benefit of the universal link component, let's look at a real-world example.

Let's say our app is built using Next.js and has this author page component:

It has several links, as you can see. All of them use the Link component we created. Let's say we want to use the same page in a Gatsby-based app.

It can be installed and integrated into that app since it is an independent component created with Bit. What about the links? In a Gatsby app, we can't use a Next.js link. We'll need to use a Gatsby link.

This is where Bit's universal link component comes in handy. We will learn throughout this post that the Link component is designed to use the routing system of the application consuming it.

We'll be able to use the same component in both apps, and the links will work. You don't have to worry about the next framework coming up and making you refactor. Write once, use everywhere.

In this post, we will examine how the link works under the hood and how it can be used. In addition, you will create a Remix link adapter to demonstrate how the link component can be used in various applications.

How to use the universal link component

The link component we're going to talk about is included in the base-react scope. This scope contains headless components without styles or markup. Let's take a look at this component. To examine the code, toggle to the code tab.

In general, the component receives the routing system via context and generates the correct link based on it.

Using the link component is very straightforward.

import { Link } from '@teambit/base-react.navigation';

<Link href="/home">Home</Link>;
CopiedCopy

The link will fallback to a basic </a> if the implementation isn't passed via context. An implementation of the routing system is passed to the NavigationProvider:

export function NavigationProvider({
  children,
  implementation,
}: {
  children: ReactNode;
  implementation: RouterContextType;
}) {
  return (
    <NavigationContext.Provider value={implementation}>
      {children}
    </NavigationContext.Provider>
  );
}
CopiedCopy

Here's how it looks in action:

import React from 'react';
import ReactDOM from 'react-dom';
import { NavigationProvider } from '@teambit/base-react.navigation.link';
import { reactRouterAdapter } from '@teambit/ui-foundation.ui.navigation.react-router-adapter';
import { BrowserRouter } from 'react-router-dom';
import { ReactTemplateApp } from './app';

ReactDOM.render(
  <BrowserRouter>
    <NavigationProvider implementation={reactRouterAdapter}>
      <ReactTemplateApp />
    </NavigationProvider>
  </BrowserRouter>,
  document.getElementById('root')
);
CopiedCopy

This example uses the react-router adapter: It passes the react-router Link to the link component. To achieve this, the RouterContextType interface must be implemented.

The section where we build our own adapter will take a closer look at how this interface is implemented.

A look under the hood

Here's how the link component works:

import React, { forwardRef } from 'react';
import { useNavigation } from './navigation-provider';
import type { LinkProps } from './link.type';
import { NativeLink } from './native-link';

/** implementation agnostic Link component, basic on the standard `a` tag */
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
  props: LinkProps,
  ref
) {
  const nav = useNavigation();
  const ActualLink = nav.Link || NativeLink;

  if (props.native || props.external) {
    return <NativeLink {...props} ref={ref} />;
  }

  return <ActualLink {...props} ref={ref} />;
});
CopiedCopy

As you can see, the component attempts to use the routing system provided by context via the useNavigation hook. It will fallback to the NativeLink component if no routing system is found. NativeLink is a simple <a></a> tag with a few additional properties:

import React, { useMemo, forwardRef } from 'react';
import classNames from 'classnames';
import { compareUrl } from '@teambit/base-ui.routing.compare-url';
import { useLocation } from './use-location';
import type { LinkProps } from './link.type';

const externalLinkAttributes = { rel: 'noopener', target: '_blank' };

export const NativeLink = forwardRef<HTMLAnchorElement, LinkProps>(
  function NativeLink(
    {
      className,
      style,
      activeClassName,
      activeStyle,
      active,
      strict,
      exact,
      href,
      external,

      // unused, but excluded from ...rest:
      native,
      state,

      ...rest
    }: LinkProps,
    ref
  ) {
    const location = useLocation();
    // skip url compare when is irrelevant
    const shouldCalcActive = !!activeClassName || !!activeStyle;

    const isActive = useMemo(() => {
      if (!shouldCalcActive) return false;
      if (typeof active === 'boolean') return active;
      if (!location || !href) return false;

      return compareUrl(location.pathname, href, { exact, strict });
    }, [active, href, location, shouldCalcActive]);

    const externalProps = external ? externalLinkAttributes : {};
    const combinedStyles = useMemo(
      () => (isActive && activeStyle ? { ...style, ...activeStyle } : style),
      [isActive, style]
    );

    return (
      <a
        {...externalProps}
        {...rest}
        ref={ref}
        href={href}
        className={classNames(className, isActive && activeClassName)}
        style={combinedStyles}
      />
    );
  }
);
CopiedCopy

The Link component can still use props such as activeClassName and activeStyle even if the routing system isn't provided via context.

We mentioned earlier that this component is framework-agnostic and does not rely on any routing systems. This ability to work even without a routing system is what makes it universal.

Returning to the link component, the routing system is obtained using the hook useNavigation.

const nav = useNavigation();
CopiedCopy

We export this hook from the navigation-provider file:

import React, { useContext, ReactNode } from 'react';
import { LinkType, UseLocation, UseNavigate } from './link.type';

export type RouterContextType = {
  /**
   * link implementation.
   */
  Link?: LinkType;

  /**
   * useLocation implementation.
   */
  useLocation?: UseLocation;

  /**
   * navigate to another page
   */
  useNavigate?: UseNavigate;
};

export const NavigationContext = React.createContext<RouterContextType>({});

/**
 * Gets routing components from context.
 * (defaults to native components)
 */
export function useNavigation() {
  const routerContext = useContext(NavigationContext);
  return routerContext;
}

export function NavigationProvider({
  children,
  implementation,
}: {
  children: ReactNode;
  implementation: RouterContextType;
}) {
  return (
    <NavigationContext.Provider value={implementation}>
      {children}
    </NavigationContext.Provider>
  );
}
CopiedCopy

When we provide the routing system via context, the useLocation and useNavigate hooks will render its implementation, and if not, they will fall back to the native implementation.

For example, here is how the useLocation hook is implemented natively:

import { useNavigation } from './navigation-provider';
import { Location } from './link.type';

export function useLocation(): Location | undefined {
  const nav = useNavigation();
  const actualUseLocation = nav.useLocation || NativeUseLocation;

  return actualUseLocation();
}

function NativeUseLocation(): Location | undefined {
  if (typeof window === 'undefined') return undefined;
  return window.location;
}
CopiedCopy

Using useNavigation again, we get the routing system, and if it is not found, we fall back to the native implementation.

In the next section, we will implement a link adapter from Remix in the next section.

Let's build a link adapter for Remix

Please make sure that the Bit binary is installed on your machine:

npx @teambit/bvm install
CopiedCopy

Let's build a Remix router adapter for the Link component!

The first step is to create a Bit Workspace. A Workspace is a development and staging area for components. Here, we create new components, retrieve and modify existing ones, and compose them together.

Bit’s workspace is where you develop and compose independent components. Components are not coupled to a workspace; you can dynamically create, fetch, and export components from a workspace and only work on them without setup.

Each component is a standalone “mini-project” and package, with its own codebase and version. The workspace makes it easy to develop, compose, and manage many components with a great dev experience.

You can develop all types of components in the workspace, which is tech-agnostic. A workspace can be used as a temporary, disposable context for components, or as a long-term workshop.

Without further ado, let's create a Workspace. In the designated directory, run the following command:

$bit
Copiedcopy

In the root of your workspace, two files and one directory will be created:

  • workspace.jsonc: This file contains the configuration of the Workspace.
  • .bitmap: Here Bit keeps track of which components are in your workspace. You shouldn't edit this file directly.
  • .bit: This is where the component's objects are stored.

Here is how the Workspace looks like:

MY-WORKSPACE
.bit
.bitmap
workspace.jsonc

Please ensure you replace "defaultScope": "[your-bit-cloud-username].[scope-name]" with your Bit Cloud user name and the scope name.

The next step is to fork an existing implementation of the React-Router adapter:

$bit
Copiedcopy

Be sure to replace the scope name with your actual scope name. I use nitsan770.universal-link.

The files in the generated folder can be renamed remix-router-adapter as follows:

Remix uses React-Router behind the scenes, so its adapter implementation is almost identical to that of react-router.

In our workspace, let's install Remix:

$bit
Copiedcopy

Here is how the Remix Link looks:

import React, { useMemo, forwardRef } from 'react';
import classnames from 'classnames';
import { Link, NavLink } from '@remix-run/react';
import { parsePath } from 'history';
import { LinkProps } from '@teambit/base-react.navigation.link';

export const RemixLink = forwardRef<HTMLAnchorElement, LinkProps>(
  function LinkWithRef(
    {
      children = null,
      href = '',
      state,
      style,
      className,
      activeClassName,
      activeStyle,
      active,
      exact,
      native,
      external,
      ...props
    }: LinkProps,
    ref
  ) {
    const to = useMemo(() => ({ ...parsePath(href), state }), [href, state]);

    if (activeClassName || activeStyle) {
      return (
        <NavLink
          to={to}
          ref={ref}
          end={exact}
          style={({ isActive }) => ({
            ...style,
            ...((active ?? isActive) && activeStyle),
          })}
          className={({ isActive }) =>
            classnames(className, (active ?? isActive) && activeClassName)
          }
          {...props}
        >
          {children}
        </NavLink>
      );
    }

    return (
      <Link {...props} className={className} style={style} to={to} ref={ref}>
        {children}
      </Link>
    );
  }
);
CopiedCopy

Here's how we pass it along to the provider:

import { useLocation, useNavigate } from '@remix-run/react';
import {
  RouterContextType,
  UseLocation,
} from '@teambit/base-react.navigation.link';
import { RemixLink } from './remix-router-link';

export const remixRouterAdapter: RouterContextType = {
  Link: RemixLink,
  useLocation: useLocation as UseLocation,
  useNavigate,
};
CopiedCopy

As soon as we tag & export the component, we can use it in our app:

Run the following command to tag the component:

$bit
Copiedcopy

And export it:

$bit
Copiedcopy

In another folder, let's initailize a Remix project:

npx create-remix@latest
CopiedCopy

In the generated folder, let's install the adapter. You can use any package manager you want. I'll be using pnpm for this tutorial:

$bit
Copiedcopy

Now let's install the link component the same way we did with the adapter:

$bit
Copiedcopy

Let's wrap our app with the provider. Paste this snippet into your app/entry.client.tsx file:

import { RemixBrowser } from '@remix-run/react';
import { hydrateRoot } from 'react-dom/client';
import { NavigationProvider } from '@teambit/base-react.navigation.link';
import { remixRouterAdapter } from '@nitsan770/universal-link.navigation.remix-router-adapter';

hydrateRoot(
  document,
  <NavigationProvider implementation={remixRouterAdapter}>
    <RemixBrowser />
  </NavigationProvider>
);
CopiedCopy

Let's add a link in the index.tsx route:

import { Link } from '@teambit/base-react.navigation.link';

export default function Index() {
  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
      <h1>
        <Link href="/author">Go to author page</Link> <---- this is the link
      </h1>
      <ul>
        <li>
          <a
            target="_blank"
            href="https://remix.run/tutorials/blog"
            rel="noreferrer"
          >
            15m Quickstart Blog Tutorial
          </a>
        </li>
        <li>
          <a
            target="_blank"
            href="https://remix.run/tutorials/jokes"
            rel="noreferrer"
          >
            Deep Dive Jokes App Tutorial
          </a>
        </li>
        <li>
          <a target="_blank" href="https://remix.run/docs" rel="noreferrer">
            Remix Docs
          </a>
        </li>
      </ul>
    </div>
  );
}
CopiedCopy

Now let's create another route in the routes folder:

import { Link } from '@teambit/base-react.navigation.link';

export default function Author() {
  return (
    <Link href="/">
      <div>Home page</div>
    </Link>
  );
}
CopiedCopy

If you run npm run dev you'll see that navigating between the pages will not reload the app. That means that the link is working as expected. :)

Check out this React Native adapter if you want to see how it works.

Summary

Being a UI library (rather than a framework), React has no opinion about how you implement routing in your app. Having such a wide range of options is helpful, as it allows you to select the right solution for your project.

When developing composable components, it is important to remember that they should be reusable as much as possible. Framework-agnostic components are more reusable than those tied to particular libraries.

As we saw in this post, the link component should not know about the routing system in the application. No matter what routing system the app uses, it should work. That's what makes it reusable.

It is for this reason that we have developed our headless link component. By receiving the routing system via context, it generates the correct link. Thus, the link component does not depend on the routing system and can be used in any app.

A component created with this link component can be reused in any app that uses its own routing system - Next Link, Gatsby Link, React Router Link, Remix Link - any of them will work.

There is still a lot to learn about component-based software engineering. Please visit the Documentation for more information. Feel free to join the community Slack channel if you have any questions.