react-listing-engine
v0.10.0
Published
Headless, composable listing engine for React: filterable list + Google-Maps multi-layer map, with pluggable data adapters, a filter/dataset registry, injectable components, and a Tailwind-free styled adapter.
Downloads
7,294
Maintainers
Readme
react-listing-engine
Headless, composable listing engine for React: filterable list + Google-Maps multi-layer map, with pluggable data adapters, a filter/dataset registry, injectable components, and a Tailwind-free styled adapter. Drop into property search, business directories, store locators, or any map+list browse experience.
Features
- Headless core + React bindings.
~/coreis framework-agnostic TypeScript (no React import);react-listing-engineadds hooks and compound components on top. The Tailwind-free styled adapter (the turnkeyListingApp+react-listing-engine/styles.css) is opt-in. - Generic over any entity. Implement
EntityAdapter<TEntity, TFilters>(list,getPoints, optionalgetById) against your own API — the engine never assumes a shape. URL serialization is a separate concern, handled byUrlSyncController(seewithUrlSync), not the adapter. - Fully customizable filters.
FilterRegistrysupportsadd/remove/reorder/replaceat runtime, not just at setup. - Multiple marker layers.
DatasetRegistrycomposes any number of layers (properties, businesses, …) onto one map; each is its ownDatasetDefinitionwith its own adapter and marker renderer. - Google Maps behind a provider seam.
MapProvideris an interface —googleProvider({ apiKey })is the shipped implementation. The API key always comes from your own config; there is no hardcoded fallback. - Component injection.
ListingComponentsProvideroverrides any UI slot (Card,Marker,Popup,Sidebar,FilterPanel,Search,Empty,Loading,ResultHeader,Toolbar) — anything not provided falls back to an unstyled (or, with the/styledadapter, styled) default.MarkerandPopupare defined onIListingComponentsbut not yet wired into the map's render output —ListingMapcurrently renders markers via each dataset'smarker.iconUrl/marker.elementonly; injectingMarker/Popuptoday has no visible effect. - URL sync.
UrlSyncController(viawithUrlSync) keeps filters in sync with your router/history without the engine depending on a specific router. - Dual ESM + CJS, full TypeScript types.
sideEffects: false, tree-shakeable subpaths,'use client'banners for Next.js App Router.
Install
pnpm add react-listing-enginereact >=18 is a required peer dependency. Three more peers are declared but optional:
@googlemaps/js-api-loader— only needed if you usereact-listing-engine/maps/google.@googlemaps/markerclusterer— only needed forDatasetDefinition.clusteringsupport ongoogleProvider; without it, clustered layers fall back to plain (unclustered) markers with a one-time console warning, no crash.@radix-ui/react-slot— reserved for future compound-component support; no shipped component currently imports it.
Quickstart
Minimal setup — one dataset, one filter, and Google Maps, rendered by the turnkey ListingApp (it wires the provider, the styled defaults, and the layout together). Define your own entity + filter shapes and back them with an EntityAdapter:
import {
ListingApp,
type EntityAdapter,
type FilterControlProps,
} from 'react-listing-engine';
import 'react-listing-engine/styles.css';
interface Property { id: string; title: string; price: number; lat: number; lng: number }
interface Filters { q?: string }
// Your API-backed adapter: `list(filters, page)` for the results, `getPoints(filters, bounds)`
// for the map pins. The engine never makes an HTTP call itself.
declare const adapter: EntityAdapter<Property, Filters>;
const SearchControl = ({ onChange, value }: FilterControlProps<string>) => (
<input onChange={e => onChange(e.target.value)} placeholder="Search" value={value} />
);
export function PropertySearch() {
return (
<ListingApp<Property, Filters>
datasets={[{ id: 'properties', adapter, marker: {} }]}
filters={reg =>
reg.add<string>({
key: 'q',
order: 0,
render: SearchControl,
toParams: v => ({ q: v || undefined }),
fromParams: f => f.q ?? '',
})
}
map={{ apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY! }}
/>
);
}Customization tiers
The engine is layered so you can go as deep as you need and stop:
- Data — implement
EntityAdapter<TEntity, TFilters>against your own API. Nothing in the engine assumes a specific backend or entity shape. - Structure — compose the provider with
composeListingProviders(withMap(...), withDataset(...), withFilters(...), withUrlSync(...), withInitialFilters(...), withPrimaryDataset(...), withConfig(...)). Mutate filters viaFilterRegistry(add/remove/reorder/replace) and layers viaDatasetRegistry(add/get/has/list/visibleIds). - Presentation — swap any slot via
ListingComponentsProvider(or start fromreact-listing-engine/styled'sStyledComponentsProviderWithDefaultsfor the styled look and override only what you need). Injectable slots:Card,Marker,Popup,Sidebar,FilterPanel,Search,Empty,Loading,ResultHeader,Toolbar.Marker/Popupare defined but not yet wired into the map's render output (see the Features note above) — every other slot renders as described. - Layout — skip
StyledListingLayoutentirely and arrange the structure-only compound components yourself:ListingList,ListingMap,ListingFilters,ListingResultHeader,ListingToolbar,ListingPagination.
Google Maps setup
import { googleProvider } from 'react-listing-engine/maps/google';
const map = googleProvider({
apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!, // required — no hardcoded fallback
mapId: 'YOUR_MAP_ID', // optional — defaults to Google's zero-config dev 'DEMO_MAP_ID'
});apiKeyalways comes from your env/config;googleProviderthrows immediately if it's falsy.mapIdis what Google requires to renderAdvancedMarkerElementmarkers. It defaults to Google's documented zero-config dev Map ID ('DEMO_MAP_ID'), so markers work out of the box — supply your own Cloud Console Map ID for production traffic or Cloud-based map styling.mapOptions(optional) forwards extragoogle.maps.MapOptionsto every map the provider creates — zoom envelope (minZoom/maxZoom), UI chrome (disableDefaultUI,zoomControl), gesture handling, etc. The provider's ownmapId/center/zoomalways win over it.styles(optional) applies legacy JSON map styling (google.maps.MapTypeStyle[]). Mutually exclusive withmapId— Google ignores JSON styles whenever a Map ID is present — so setting it switches the provider into a no-Map-ID mode that renders markers asOverlayViewHTML overlays instead ofAdvancedMarkerElements. Marker clustering isn't supported in this mode (it falls back to plain markers with a one-time console warning).loaderOptions(optional) forwards extra@googlemaps/js-api-loaderconfig (language, region, preloaded libraries);keyis always taken fromapiKeyand can't be overridden there.DatasetDefinition.clustering({ maxZoom }orfalse) is implemented by the shippedgoogleProvider: when set, that layer's markers are wrapped in aMarkerClusterer(from the OPTIONAL peer dependency@googlemaps/markerclusterer— install it to enable clustering; without it,googleProviderwarns once and falls back to plain, unclustered markers, no crash) with a custom renderer that draws a solid red circle showing the cluster's count. No Mapbox provider ships either;MapProvideris the seam if you want to add one.
Multiple marker layers
A second marker layer (nearby businesses, schools, transit, …) composes onto the same map with one more withDataset call — its own EntityAdapter and marker:
withDataset({
id: 'businesses',
adapter: businessesAdapter, // your EntityAdapter for the second layer
marker: { iconUrl: b => categoryIcons[b.category] },
})Additional datasets are map-only layers: only the primary dataset (the first one added, or whichever id you pass as primaryDatasetId via withPrimaryDataset) drives the results list and pagination — implementing list on a secondary dataset's adapter has no effect on the list/pagination unless that dataset is made primary.
Filter-shape caveat. ListingEngine.loadPoints calls every visible layer's getPoints with the primary dataset's TFilters — there's a single filter state per engine, not one per layer. A secondary dataset whose filters have a genuinely different shape is not driven by the engine's filter state at all; the engine's filter object simply isn't in that shape by the time it reaches the layer's adapter. Filter such a layer at construction instead — close over the restriction in the adapter you hand to withDataset — rather than reading it from useListingFilters().
Styled adapter
The default UI is the Tailwind-free /styled adapter: the turnkey ListingApp (above), or the lower-level StyledListingLayout + StyledComponentsProviderWithDefaults from react-listing-engine/styled. It ships self-contained CSS — no Tailwind, no build step, no token setup. Import the stylesheet once:
import { ListingApp } from 'react-listing-engine';
import 'react-listing-engine/styles.css';Every visual value is a --rle-* CSS variable declared on :root, so you retheme the whole UI by overriding a subset in your own CSS loaded after the stylesheet:
@import 'react-listing-engine/styles.css';
:root {
--rle-primary: #0ea5e9;
--rle-radius: 4px;
}Hooks
useListing()— the activeListingEnginefrom context; throws outside a<ListingProvider>.useListingState()— the full store snapshot, subscribed viauseSyncExternalStore.useListingResults()— just the paginated results slice.useListingFilters()— current filters plusset(patch)/setField(key, value).useListingMap()— bounds + per-dataset points, plusloadPoints(bounds)/selectPoint(datasetId, id)and map actions:zoomIn()/zoomOut()/toggleFullscreen()/fitBounds(bounds)(flies the mounted map to a bounding box; safe no-op without a mounted map — the resulting bounds-changed event then reloads points for the new area like any user pan).useListingLayer(id)— one dataset's visibility, points, and atoggle().useListingEvent(type | '*', handler)— subscribe to engine events for the component's lifetime.
Support
If react-listing-engine saves you time, consider sponsoring continued maintenance:
License
MIT (c) knazark
