@creative-locator/react
v0.2.0
Published
React bindings for Creative Locator. Drop-in <DealerLocator /> component + mount-once hooks (useDealerLocator / useDealerLocatorState / useDealerEvents) + Next.js 15 App Router Server Component entry resolved via the react-server exports condition.
Maintainers
Readme
@creative-locator/react
React component bindings for Creative Locator.
A thin wrapper around @creative-locator/headless that gives React applications a one-component install: drop a <DealerLocator /> into your JSX, pass an apiUrl, render. The component derives the host container from a ref so you never have to manage document.querySelector calls or worry about hydration timing.
Install
pnpm add @creative-locator/core @creative-locator/headless @creative-locator/react leaflet leaflet.markerclusterreact and react-dom are peer dependencies (^18 || ^19). The package does not bundle React.
Usage
import { DealerLocator } from '@creative-locator/react';
import '@creative-locator/core/styles/style.scss';
export function LocatorPage() {
return (
<DealerLocator
apiUrl="https://example.com/wp-json/creative-locator/v1"
style={{ height: '600px' }}
/>
);
}The component renders a single <div> as the host element. Sizing is the host application's responsibility — apply height via the style prop, a CSS class, or a parent container. Without a non-zero height, the locator's map will not be visible.
Props
DealerLocator accepts every option from createHeadlessLocator except container (which is derived from the component's internal ref), plus two layout-shaped props for the wrapping <div>:
| Prop | Type | Default | Notes |
| ----------------------- | ---------------------------------- | -------- | ----------------------------------------- |
| apiUrl | string | — | Required. Creative Locator REST base URL. |
| config | Partial<LocatorConfig> | {} | Merged over defaultLocatorConfig. |
| apiHeaders | Record<string, string> | — | Auth / nonce headers. |
| fetch | typeof globalThis.fetch | global | Override for SSR or test contexts. |
| storage | 'browser' \| 'memory' \| Storage | auto | Auto-detects SSR. |
| enableGoogleAnalytics | boolean | false | GA bridge off by default in headless. |
| licenseTier | 'free' \| 'pro' | 'free' | Gates Pro layouts client-side. |
| adapters | Partial<PlatformAdapters> | — | Per-slot adapter overrides (advanced). |
| className | string | — | Applied to the wrapping <div>. |
| style | React.CSSProperties | — | Applied to the wrapping <div>. |
See @creative-locator/headless for the canonical option reference.
Mount-once contract
Like most map libraries, the locator is expensive to initialize. The component intentionally ignores prop changes after the initial mount — re-rendering with a new apiUrl or config does NOT recreate the locator. If you need to swap configuration at runtime, remount the component via React's key prop:
<DealerLocator key={apiUrl} apiUrl={apiUrl} />This is the same trade-off Leaflet wrappers (react-leaflet etc.) make for the same reason.
Hooks (#994 PR A)
Three React hooks expose the underlying DealerLocator for templates that want reactive access to locator state and typed event subscription. The component above is the simplest entry; hooks are the idiomatic shape for richer integrations. See docs/adr/ADR-010-framework-adapter-composables.md for the design.
import { useRef } from 'react';
import { useDealerLocator, useDealerLocatorState, useDealerEvents } from '@creative-locator/react';
export function LocatorPage() {
const root = useRef<HTMLDivElement | null>(null);
const { locator } = useDealerLocator(root, { apiUrl: '...' });
const { allDealers, currentRadius } = useDealerLocatorState(locator);
useDealerEvents(locator, 'marker:click', ({ location }) => {
console.log('clicked', location.id);
});
return (
<>
<div ref={root} style={{ height: 600 }} />
<p>
{allDealers.length} dealers within {currentRadius} mi.
</p>
</>
);
}useDealerLocatorState uses useSyncExternalStore so concurrent-mode React reads a consistent snapshot. The snapshot identity is cached per locator — destructured fields keep referential stability across renders when state hasn't moved.
Next.js 15 App Router (#994 PR B)
The package ships a react-server exports condition. Inside a Server Component, import { DealerLocator } from '@creative-locator/react' resolves to the Server Component entry — an async function that renders the static shell HTML server-side, optionally fetches and inlines the initial dealer payload, and hands off interactivity to a Client Component child on hydration.
// app/locator/page.tsx — runs on the server
import { DealerLocator } from '@creative-locator/react';
import '@creative-locator/core/styles/style.scss';
export default function LocatorPage() {
return (
<DealerLocator
apiUrl="https://example.com/wp-json/creative-locator/v1"
style={{ height: 600 }}
/>
);
}The Server Component issues a fetch(${apiUrl}/dealers?limit=20) itself by default (Next 15's fetch cache + dedupe makes this effectively free) and emits the result as an inline <script type="application/json"> payload that the client-side HydrationOrchestrator.tryHydrateFromInlinePayload() picks up. The user sees dealer markers on first paint, before any REST round-trip resolves on the client.
Pass initialPayload to skip the SC's own fetch when you've already fetched the data upstream:
export default async function LocatorPage() {
const res = await fetch('https://example.com/wp-json/creative-locator/v1/dealers?limit=50');
const json = await res.json();
return (
<DealerLocator
apiUrl="https://example.com/wp-json/creative-locator/v1"
initialPayload={{ dealers: json.dealers, total: json.total }}
fetchInitialPayload={false}
/>
);
}Server Component props
| Prop | Type | Default | Notes |
| --------------------- | -------------------- | -------------------------- | --------------------------------------------------------------------------------------------- |
| apiUrl | string | — | Required. Creative Locator REST base URL. |
| initialPayload | InlinePayload | — | Pre-fetched { dealers, total, cached_at? }. Skips the SC's own fetch. |
| fetchInitialPayload | boolean | true | Set false to skip server-side data resolution (client-only data flow). |
| ssrCap | number | 20 | Server-fetch limit. Mirrors WP crloc_ssr_bootstrap_cap. |
| bootstrapId | string | ${instanceId}__bootstrap | DOM id of the inline payload <script>. Consumer-passed config.ssrBootstrapId wins if set. |
| instanceId | string | crloc-locator-1 | Wrapper element id. Multi-locator pages need a unique value per instance. |
| shellOptions | RenderShellOptions | — | Pass-through to renderLocatorShell (mapPanelInert, labels). |
| config | Partial<Config> | — | Same as the SPA component. |
| apiHeaders | Record<string, …> | — | Serializable headers only — no functions. |
Non-serializable overrides (fetch, adapters) are not exposed here because they can't cross the RSC → Client boundary. Compose your own Client Component if you need them.
Subpath imports (escape hatch)
The react-server condition does the right thing automatically. For cases where you need to bypass the conditional resolution:
@creative-locator/react/server— always the Server Component.@creative-locator/react/client— always the legacy SPA component + hooks.
SPA-only consumers
If you're not on the App Router (e.g. still on Pages Router, CRA, or Vite SPA), the package's default condition resolves to the legacy client-only <DealerLocator /> — the same drop-in wrapper that's been here since #515. Nothing changes for those consumers.
Astro
Use the @astrojs/react integration to render the component as a client island:
---
// src/pages/locator.astro
import { DealerLocator } from '@creative-locator/react';
import '@creative-locator/core/styles/style.scss';
---
<DealerLocator
client:only="react"
apiUrl={import.meta.env.PUBLIC_LOCATOR_API_URL}
style={{ height: '600px' }}
/>client:only="react" skips SSR entirely (Leaflet needs window).
License
GPL-2.0-or-later
