# React Snippets for Airgap (/docs/consent-management/reference/airgap/react-snippets)

## Overview [#overview]

This documentation site uses React and Next.js. Here are some snippets and recipes for how use `airgap.js` in this codebase:

## Load Airgap asynchronously in Next.js with a `useConsentManager` hook. [#load-airgap-asynchronously-in-nextjs-with-a-useconsentmanager-hook]

<Callout type="warn">
  Beware: the following script imports airgap.js **asynchronously**. Since airgap.js can only regulate network traffic after it is loaded, you become responsible for ensuring no trackers load before airgap.js! See [Loading Asynchronously](../../configuration/installing-airgap/loading-asynchronously.mdx) for more info.

  To load **synchronously**, see [Load Airgap synchronously in Next.js](#load-airgap-synchronously-in-nextjs).
</Callout>

`lib/hooks/useConsentManager.tsx`:

```tsx
import { createContext, useContext, useEffect, useState } from 'react';
import Script from 'next/script';
import type {
  AirgapAPI,
  PreInitTranscendAPI,
  TranscendAPI,
} from '@transcend-io/airgap.js-types';

/** A stub for `window.airgap` to be used before airgap is actually loaded */
type PreInitAirgapAPI = Required<Pick<AirgapAPI, 'readyQueue' | 'ready'>>;

/**
 * Add `window.airgap` and `window.transcend` to the global namespace
 */
declare global {
  interface Window {
    /** airgap.js interface */
    airgap?: PreInitAirgapAPI | AirgapAPI;
    /** Transcend consent manager interface */
    transcend?: PreInitTranscendAPI | TranscendAPI;
  }
}

/**
 * The `airgap` and `transcend` consent management APIs
 *
 * @example const { airgap, transcend } = useConsentManager();
 */
interface ConsentAPI {
  /** The `airgap` consent management instance */
  airgap?: AirgapAPI;
  /** The `transcend` consent manager UI instance */
  transcend?: TranscendAPI;
}

interface ConsentProviderProps {
  /** The children of this context provider */
  children: React.ReactNode;
  /** The src URL from the Transcend CDN */
  airgapSrc: string;
}

/** The React context by the `useConsentManager()` hook */
export const ConsentContext = createContext<ConsentAPI>({});

/**
 * React context provider for `window.airgap` and `window.transcend`
 * @see https://docs.transcend.io/docs/consent/faq
 */
export const ConsentProvider: React.FC<ConsentProviderProps> = ({
  children,
  airgapSrc,
}) => {
  const [airgap, setAirgap] = useState<AirgapAPI | undefined>();
  const [transcend, setTranscend] = useState<TranscendAPI | undefined>();

  // `useEffect` ensures this is only executed in browser
  useEffect(() => {
    // Stub transcend with PreInit API
    if (!self.transcend?.ready) {
      const preInitTranscend: PreInitTranscendAPI = {
        readyQueue: [],
        ready(callback) {
          this.readyQueue.push(callback);
        },
        ...self.transcend,
      };
      self.transcend = preInitTranscend;
    }

    // Stub airgap with PreInit API
    if (!self.airgap?.ready) {
      const preInitAirgap: PreInitAirgapAPI = {
        readyQueue: [],
        ready(callback) {
          this.readyQueue.push(callback);
        },
        ...self.airgap,
      };
      self.airgap = preInitAirgap;
    }

    // Wait for consent manager UI to load, and set it in the React state
    if (!transcend) {
      self.transcend.ready((transcend) => {
        setTranscend(transcend);
      });
    }

    // Wait for airgap.js core to load, and set it in the React state
    if (!airgap) {
      self.airgap.ready((airgap) => {
        setAirgap(airgap);
      });
    }
  }, [airgap, transcend]);

  return (
    <>
      <Script src={airgapSrc} />
      <ConsentContext.Provider value={{ airgap, transcend }}>
        {children}
      </ConsentContext.Provider>
    </>
  );
};

export const useConsentManager = (): ConsentAPI => useContext(ConsentContext);
```

And wrap your application in the consent provider.

`pages/_app.tsx`:

```tsx
import { ConsentProvider } from '../lib/hooks/useConsentManager';

export default function MyApp({ Component, pageProps }) {
  return (
    // Replace `airgapSrc` with your bundle URL
    <ConsentProvider airgapSrc="https://transcend-cdn.com/cm/f8d8c73f-3189-4ab4-c138-fa9922f76812/airgap.js">
      <Component {...pageProps} />;
    </ConsentProvider>
  );
}
```

Now your components can interact with `window.airgap` and the consent manager UI (`window.transcend`) by calling `const { transcend, airgap } = useConsentManager();`. See the next snippet for an example!

**Beware: you are now importing airgap.js asynchronously. Since airgap.js can only regulate network traffic after it is loaded, you are now responsible for ensuring no trackers load before airgap.js!** Here is an example of how you could asynchronously load [@segment/analytics-next](https://github.com/segmentio/analytics-next/tree/master/packages/browser#lazy--delayed-loading) using your new `useConsentManager` hook:

```tsx
import { AnalyticsBrowser } from '@segment/analytics-next';

const analytics = new AnalyticsBrowser();

export const AnalyticsProvider: React.FC<{ children: ReactNode }> = ({
  children,
}) => {
  const { airgap } = useConsentManager();

  // Load analytics.js
  useEffect(() => {
    // Make sure Airgap is enabled before attempting the import
    if (airgap) {
      const writeKey = process.env.NEXT_PUBLIC_SEGMENT_WRITE_KEY;
      // Load Segment analytics
      analytics.load({ writeKey });
    }
  }, [airgap]);

  // ...
};
```

This snippet is based on the [Next.js analytics-next](https://github.com/segmentio/analytics-next/blob/930af49b27f7c2973304c7ae76b67d264223e6f6/examples/with-next-js/context/analytics.tsx#L13) example.

### Wrapper for `next/script` [#wrapper-for-nextscript]

To condition tracking script loads on airgap being ready, here is a replacement for the `<Script>` component from [`next/script`](https://nextjs.org/docs/pages/api-reference/components/script):

```tsx
// import Script from 'next/script';
import TrackingScript from './lib/consent/tracking-script';

// <Script
//  src="https://cdn.example.com/analytics.js"
//  strategy="lazyOnload"
// />;
<TrackingScript
  src="https://cdn.example.com/analytics.js"
  strategy="lazyOnload"
  loadAfter={loadAfter}
/>;

```

`lib/consent/tracking-script.tsx`:

````tsx
'use client';

import Script, { type ScriptProps } from 'next/script';
import { type ReactElement, type ReactNode, useEffect, useState } from 'react';

export interface TrackingScriptProps extends ScriptProps {
  /**
   * Wait for this promise before injecting the underlying `<Script>`.
   * Use `Promise.all(...)` or `Promise.race(...)` at the call site to compose
   * multiple conditions. If the promise rejects, the script remains unloaded.
   */
  loadAfter: PromiseLike<unknown>;
  /** Inline script body (for `<TrackingScript id="...">{`...`}</TrackingScript>`). */
  children?: ReactNode;
}

/**
 * Client component that renders `next/script` only after `loadAfter` resolves.
 *
 * Like `strategy="afterInteractive"`, this renders nothing on the server and
 * initially renders nothing on the client. `strategy="beforeInteractive"` is
 * unsupported because the script is gated after hydration.
 *
 * @example
 * ```tsx
 * import { TrackingScript } from '@transcend-io/react-airgap';
 *
 * const analyticsReady = new Promise<void>((resolve) => {
 *   window.addEventListener('analytics:ready', () => resolve(), { once: true });
 * });
 *
 * <TrackingScript
 *   src="https://cdn.example.com/analytics.js"
 *   strategy="afterInteractive"
 *   loadAfter={analyticsReady}
 * />
 * ```
 */
export default function TrackingScript({
  loadAfter,
  children,
  ...scriptProps
}: TrackingScriptProps): ReactElement | null {
  const [open, setOpen] = useState(false);

  // Dev-only guard: `beforeInteractive` requires the script to be SSR'd, which
  // this wrapper deliberately prevents. Warn loudly rather than silently
  // degrade.
  if (process.env.NODE_ENV !== 'production' && scriptProps.strategy === 'beforeInteractive') {
    // eslint-disable-next-line no-console
    console.warn(
      '[TrackingScript] strategy="beforeInteractive" is not supported — the script is gated client-side and cannot appear in SSR HTML. Use "afterInteractive" or "lazyOnload".',
    );
  }

  useEffect(() => {
    let cancelled = false;
    void Promise.resolve(loadAfter).then(
      () => {
        if (!cancelled) setOpen(true);
      },
      () => undefined,
    );
    return () => {
      cancelled = true;
    };
  }, [loadAfter]);

  if (!open) return null;

  // Support both external (`src`) and inline (`children`) forms.
  return <Script {...scriptProps}>{children}</Script>;
}
````

You can pass any promise to `loadAfter`...

**Wait on Airgap being ready:**

```tsx
const airgapReadyPromise = new Promise<EventTarget>((resolve) => {
  if (!self.airgap?.ready) {
    const readyQueue = self.airgap?.readyQueue ?? [];
    self.airgap = {
      ...self.airgap,
      readyQueue,
      ready(callback) {
        readyQueue.push(callback);
      },
    };
  }

  self.airgap.ready((airgap) => resolve(airgap));
});

<TrackingScript
  src="https://cdn.example.com/analytics.js"
  strategy="afterInteractive"
  loadAfter={airgapReadyPromise}
/>;
```

**Wait on a** **`CustomEvent`:**

```tsx
const trackersCanLoadPromise = new Promise<void>((resolve) => {
  window.addEventListener('trackers:allow-load', () => resolve(), { once: true });
});

<TrackingScript
  src="https://cdn.example.com/analytics.js"
  strategy="afterInteractive"
  loadAfter={trackersCanLoadPromise}
/>
```

## Load Airgap synchronously in Next.js [#load-airgap-synchronously-in-nextjs]

### Pages Router [#pages-router]

If using the Pages Router, update your [Custom Document](https://nextjs.org/docs/pages/building-your-application/routing/custom-document).

`pages/_document.tsx`:

```tsx
import { Head, Html, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html lang="en">
      <Head>
        {/* Snippet from https://app.transcend.io/consent-manager/developer-settings/installation */}
        <script
          data-cfasync="false"
          src="https://transcend-cdn.com/cm/<your-airgap-id>/airgap.js"
        ></script>
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}
```

## Add the "Your Privacy Choices" Button to Your Footer [#add-the-your-privacy-choices-button-to-your-footer]

Add California's CCPA/CPRA "Your Privacy Choices" [opt-out icon](https://oag.ca.gov/privacy/ccpa/icons-download) to your website footer (replacing the "Do Not Sell or Share My Personal Information" link).

![Your Privacy Choices CPRA opt out icon](/images/2023-05-15-your-privacy-choices-footer.png)

`components/YourPrivacyChoices.tsx`:

```tsx
import React, { useEffect, useState } from 'react';

import { useConsentManager } from '../lib/hooks/consent-manager';
/**
 * This import assumes you are using `@svgr/webpack` to load SVGs as React components
 * @see https://www.npmjs.com/package/@svgr/webpack
 * @see https://react-svgr.com/
 */
import PrivacyChoicesIcon from './privacy-choices-icon.svg';

export interface YourPrivacyChoicesProps {}

export const YourPrivacyChoicesInterior: React.FC<
  JSX.IntrinsicElements['button']
> = ({ onClick }) => {
  return (
    <>
      <button onClick={onClick} className="privacy-choices-button">
        <div className="privacy-choices-button-layout">
          <PrivacyChoicesIcon className="privacy-choices-icon" />
          <span>Your Privacy Choices</span>
        </div>
      </button>
      <style jsx>{`
        .privacy-choices-button-layout {
          display: flex;
          align-items: center;
        }

        .privacy-choices-button {
          all: unset;
        }

        .privacy-choices-button {
          cursor: pointer;
          height: var(--lineHeight);
        }

        .privacy-choices-button-layout :global(.privacy-choices-icon) {
          padding-right: 0.4em;
          height: 0.9em;
        }
      `}</style>
    </>
  );
};

/**
 * CPRA Your Privacy Choices button
 *
 * @see https://cppa.ca.gov/regulations/pdf/20221102_mod_text.pdf
 * @see https://oag.ca.gov/privacy/ccpa/icons-download
 */
export const YourPrivacyChoices: React.FC<YourPrivacyChoicesProps> = ({}) => {
  const { transcend, airgap } = useConsentManager();
  const [consentEnabled, setConsentEnabled] = useState<boolean>(false);

  useEffect(() => {
    // Show the 'Your Privacy Choices' button based on `airgap.getRegimes`
    function getConsentEnabled(): boolean {
      if (!airgap) {
        return false;
      }
      const regimes = airgap.getRegimes();

      // We display this button if CPRA is present in the detected regimes
      const consentEnabled =
        typeof regimes !== 'undefined' && regimes.has('CPRA');

      return consentEnabled;
    }
    setConsentEnabled(getConsentEnabled());
  }, [airgap]);

  if (!consentEnabled) {
    return null;
  }

  return (
    <YourPrivacyChoicesInterior
      onClick={() =>
        transcend?.showConsentManager({ viewState: 'CompleteOptions' })
      }
    />
  );
};
```

And lastly, [here is a download link](/assets/privacy-choices-icon.svg) for a compressed + optimized SVG of the CPRA opt out icon (imported above as `'./privacy-choices-icon.svg'`)

## Add a `ConsentBoundary` React Component [#add-a-consentboundary-react-component]

Similar to a [\<Suspense> boundary](https://react.dev/reference/react/Suspense), this `<ConsentBoundary>` component lets you display a fallback component until a user has given consent. This is a good option for components which connect to services that require consent.

<Video src="/videos/2024-08-05-consent-boundary-demo.webm" autoPlay loop />

### Usage: [#usage]

```tsx
<ConsentBoundary
  urlsRequiredForRender={[props.videoUrl]}
  fallback={({ missingConsentPurposes, onConsentGiven }) => (
    <div>
      <p>
        This media requires consent for {[...missingConsentPurposes].join(', ')}
      </p>
      <button onClick={onConsentGiven}>Opt in</button>
    </div>
  )}
>
  <ReactPlayer url={props.videoUrl} />
</ConsentBoundary>
```

Here's an example of that `ConsentBoundary` component. It uses Next.js and TailwindCSS, but you can make it your own.

`components/ConsentBoundary.tsx`:

```tsx
import React, { MouseEventHandler, useEffect, useState } from 'react';
import Link from 'next/link';
import { AirgapAPI, TrackingConsent } from '@transcend-io/airgap.js-types';

import { useConsentManager } from '@/lib/consent-manager'; // see `useConsentManager` snippet

import { Button } from './Button';
import { TranscendLogo } from './TranscendLogo';

export interface FallbackProps {
  /** A set of consent purposes that are required but missing for the `urlsRequiredForRender` */
  missingConsentPurposes: Set<string>;
  /** The onClick event if the user opts in, which sets airgap.setConsent(event.nativeEvent, targetTrackingConsent) */
  onConsentGiven: MouseEventHandler;
}

/**
 * For a given set of URLs, get the purposes that are missing consent to connect to those URLs.
 */
async function getMissingConsentPurposesForUrls(
  airgap: AirgapAPI,
  urls: string[],
): Promise<FallbackProps['missingConsentPurposes']> {
  // A set of missing consent purposes for the current user.
  const missingConsentPurposes = new Set<string>();
  await Promise.all(
    urls.map(async (url) => {
      const requestIsAllowed: boolean = await airgap.isAllowed(url);
      if (requestIsAllowed) return;

      const purposesRequired: Set<string> = await airgap.getPurposes(url);

      // The purposes that are required but missing for the current user
      const purposesMissing = [...purposesRequired].filter(
        (purpose) => !airgap.isConsented(new Set([purpose]),
      );

      // Special case: If the purpose is just Unknown, and the request is blocked
      if (purposesMissing.includes('Unknown')) {
        // Require consent for all purposes in the regime
        const allPurposes = airgap.getRegimePurposes();
        for (const purpose of allPurposes) {
          if (!airgap.isConsented(new Set([purpose]))) {
            missingConsentPurposes.add(purpose);
          }
        }
      } else {
        // Add the missing purposes to the set
        for (const purpose of purposesMissing) {
          missingConsentPurposes.add(purpose);
        }
      }
    }),
  );

  return missingConsentPurposes;
}

function formatWithOxfordComma(
  items: string[],
  conjunction: 'and' | 'or',
): string {
  if (items.length === 0) return '';
  if (items.length === 1) return items[0];
  if (items.length === 2) return `${items[0]} ${conjunction} ${items[1]}`;
  return `${items.slice(0, -1).join(', ')}, ${conjunction} ${items[items.length - 1]}`;
}

/**
 * A fallback component to render when the user's consent preferences prevent rendering the wrapped component.
 */
const DefaultFallback: React.FC<FallbackProps> = ({
  missingConsentPurposes,
  onConsentGiven,
}) => {
  const formattedMissingPurposes = formatWithOxfordComma(
    [...missingConsentPurposes].map((purpose) => purpose.toLowerCase()),
    'or',
  );

  return (
    <div className="flex w-full h-full items-center justify-center flex-col gap-x-10 m:gap-x-15 p-24 m:p-75 text-center">
      <p className="text-16 font-[440] leading-116 text-grayscale-06 m:text-18">
        This content requires your consent to load.
      </p>
      <p className="text-12 m:text-16">
        The technology that displays this content may collect data
        {formattedMissingPurposes.length > 0
          ? ` for ${formattedMissingPurposes} purposes`
          : ' from your device'}
        .
      </p>

      <Button
        className="my-12 m:my-24"
        variant="secondary"
        onClick={onConsentGiven}
      >
        Opt in
      </Button>

      <span className="text-12 m:text-14 text-grayscale-05 inline">
        Powered by{' '}
        <Link
          href="/platform/consent-management"
          target="_blank"
          className="hyperlink"
        >
          Transcend Consent Management
        </Link>
      </span>
      <TranscendLogo iconOnly className="w-18 mt-10 hidden m:block" />
    </div>
  );
};

/**
 * If the user has not consented to URLs that would break rendering the children, render a fallback component.
 */
export const ConsentBoundary: React.FC<{
  /** The children are rendered when `urlsRequiredForRender` satisfy the user's consent preferences. */
  children: React.ReactNode;
  /** The URLs that would break rendering of the `children`. */
  urlsRequiredForRender: string[];
  /**
   * A callback function which renders a fallback component.
   * This is rendered when the `urlsRequiredForRender` do not satisfy the user's consent preferences.
   * The callback passes information about the missing consent purposes, should an opt-in button be rendered in this fallback.
   * Defaults to a basic fallback message with an opt-in button.
   */
  fallback?: (fallbackProps: FallbackProps) => React.ReactNode;
}> = ({
  children,
  urlsRequiredForRender,
  fallback = (fallbackProps) => {
    return <DefaultFallback {...fallbackProps} />;
  },
}) => {
  const { airgap } = useConsentManager();

  // A list of consent purposes that must to be opted into for the children to render
  const [missingConsentPurposes, setMissingConsentPurposes] = useState<
    FallbackProps['missingConsentPurposes']
  >(new Set());

  // Set the missing consent purposes for the URLs required for rendering
  useEffect(() => {
    if (airgap) {
      getMissingConsentPurposesForUrls(airgap, urlsRequiredForRender).then(
        setMissingConsentPurposes,
      );
    }
  }, [airgap, urlsRequiredForRender]);

  // Whether to show the fallback component
  const [showFallback, setShowFallback] = useState(false);

  // Show the fallback component if there are missing consent purposes
  useEffect(() => {
    if (missingConsentPurposes.size > 0) {
      setShowFallback(true);
    }
  }, [missingConsentPurposes]);

  /**
   * Event handler for the fallback component to call when the user clicks the "Opt in" button via `<button onClick={onConsentGiven}>`
   * Triggers re-render of ConsentBoundary
   */
  const onConsentGiven: MouseEventHandler = async (event) => {
    if (!airgap) {
      throw new Error(
        'airgap.js is not available! Is `onConsentGiven` called outside the fallback component?',
      );
    }

    // The object to pass to airgap.setConsent(event.nativeEvent, targetTrackingConsent), if the user opts in
    const targetTrackingConsent: TrackingConsent = airgap.getConsent().purposes;
    for (const purpose of missingConsentPurposes) {
      targetTrackingConsent[purpose] = true;
    }

    // Set consent and re-render the children
    await airgap.setConsent(event.nativeEvent, targetTrackingConsent);
    setShowFallback(false);
  };

  if (showFallback) {
    // Render the fallback component by calling the implementor-supplied callback
    return (
      <>
        {fallback({ missingConsentPurposes, onConsentGiven })}
        <div hidden>{children}</div>
      </>
    );
  }

  return children;
};
```
