Inject a React component into a named slot
Add-ons put their own UI into Benecaster’s admin screens by registering React components into named extension slots via window.BenecasterExtensions.registerFill(). The add-on must ship compiled JavaScript. Benecaster draws no fields for an add-on: whatever appears in a slot — a form, a picker, a live preview, a third-party embed — is your component, and it saves through your own REST route or post meta.
Some slots need a declaration in PHP first. An episode-editor tab is declared with benecaster_episode_editor_tabs — that adds the tab’s button — and its panel is the slot you fill here. See Add a tab to the episode editor.
Prerequisites
-
Benecaster installed with a valid license
-
Your add-on ships compiled JavaScript, built with Benecaster’s React runtime mapped as externals (see below)
-
Script enqueued on
admin_enqueue_scripts(only on Benecaster admin pages)
How It Works
Three pieces, and all three are needed for an editor tab:
-
Enqueue your compiled script on
admin_enqueue_scripts, guarded so it only loads on Benecaster admin pages. Declarebenecaster-adminas a dependency so Benecaster’s runtime is published before your bundle is parsed, pluswp-dom-ready, andwp-api-fetchif your component talks to a REST endpoint. -
Register the component as a fill. Inside
wp.domReady, callwindow.BenecasterExtensions?.registerFill( slotName, Component ). Your component receives the slot’sfillProps— see the table below for what each slot passes. -
Declare the tab in PHP via
benecaster_episode_editor_tabs, with your add-on’s slug inadd_on. Without this the tab never appears and your fill has nowhere to render. Slots that already exist in Benecaster — dashboard cards, the pre-publish panel — need only steps 1 and 2.
Named Slots
| Slot name | Location | fillProps |
|---|---|---|
BenecasterEpisodePrePublish |
Pre-publish panel in the episode editor (fires before Publish is confirmed) | { episode_id, show_id } |
BenecasterEpisodeEditorTab_{id} |
Episode editor tab panel (where {id} matches the tab’s id key) |
{ episodeId, showId } |
BenecasterEpisodeEditorAfterFields |
Below all episode editor fields in any tab | { episodeId, showId } |
BenecasterEpisodeReferenceRow |
Inside each reference card on the episode editor’s References tab | { episodeId, showId, index, reference, onUpdate } |
BenecasterShowEditorAfterFields |
Below all show editor fields in the General section | { showId } |
BenecasterDashboardCards |
After built-in stat cards on the Benecaster dashboard | { showId } |
BenecasterSettingsPage_{id} |
An add-on page on the install-wide Benecaster Settings screen | none |
BenecasterShowSettingsPage_{id} |
An add-on tab inside one show’s settings, declared with benecaster_show_settings_pages |
{ showId } |
BenecasterSubscriberDetailAfter |
Below the token section in the subscriber detail slide-in panel | { subscriberId, showId } |
The four episode-editor slots hand you the episode’s own show, not the one picked in the show switcher: showId, or show_id on BenecasterEpisodePrePublish. For an existing episode it is the show the episode is saved under; for a new one, the show it will be created under. It is undefined until an existing episode has loaded. So a fill opened from a link to another show’s episode keys its data, and checks whether your add-on is on, for the right show.
BenecasterEpisodeReferenceRow renders once per reference card, and its props are the editor’s live, unsaved state. reference is the row as the editor holds it, so reference_id is null on a row just added and never saved to the show’s library. Handle that rather than assuming the key. It is filled in as soon as the episode’s save finishes, with no reload. The row’s id is replaced on every save, so key anything you store on reference_id, never id. index is the row’s position in the list and changes when a row above it is removed: use it for labelling, never for storage.
onUpdate patches reference columns only. It takes a partial reference and updates that row alone. An add-on field that is not a column of the reference (an affiliate URL, a link expiry) belongs in your add-on’s own storage, keyed by reference_id: an unknown key sent through the episode save is dropped without an error. Wait for a saved reference_id before storing anything against a row.
const TrackButton = ( { reference, showId } ) => {
if ( ! reference.reference_id ) {
// Not in the library yet: nothing stable to key our data to.
return <span>Save to track</span>;
}
return (
<button type="button" onClick={ () => openTracking( showId, reference.reference_id ) }>
Track
</button>
);
};
window.BenecasterExtensions?.registerFill( 'BenecasterEpisodeReferenceRow', TrackButton );
BenecasterEpisodePrePublishuses underscore-separated prop names (episode_id,show_id) — not camelCase. This is intentional and matches the PHP-side REST API convention for these IDs.
The two settings-page slots are easy to confuse. A fill registered under the wrong name never renders, and a fill on BenecasterSettingsPage_{id} that reads showId gets undefined. Neither raises an error. For a tab in one show’s settings, see Add an Add-on Tab to One Show’s Settings.
Build Against Benecaster’s React
Your fill renders inside Benecaster’s React tree, so it has to use Benecaster’s copy of React. A bundle that ships its own React, or builds against WordPress’s wp.element, renders plain elements fine but throws Invalid hook call on the first useState, useEffect or useContext.
Benecaster publishes its runtime on window.BenecasterExtensions for this. Map these modules as externals in your bundler and your fill shares Benecaster’s React, its data-fetching cache and its router context:
| Key | Module to map it to | Available in |
|---|---|---|
extensionRuntimeVersion |
none (a number, currently 1) |
admin and setup wizard |
React |
react |
admin and setup wizard |
ReactDOM |
react-dom |
admin and setup wizard |
ReactDOMClient |
react-dom/client |
admin and setup wizard |
ReactJSXRuntime |
react/jsx-runtime |
admin and setup wizard |
i18n |
@wordpress/i18n |
admin and setup wizard |
ReactQuery |
@tanstack/react-query |
admin only |
queryClient |
none (Benecaster’s QueryClient instance) |
admin only |
The i18n key is @wordpress/i18n version 6. Its sprintf() handles %s, %d, %.2f, positional %1$s and %%, but not width or padding (%05d, %10s), %+d, or %x, %o, %b, %e, %u, %i, %j: pre-format such a value and pass it as %s. Typed against the package, TypeScript checks arguments against the format string, so a number passed for %s needs String().
For Vite or Rollup:
// my-addon/vite.config.js
export default {
build: {
rollupOptions: {
external: [ 'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime',
'@wordpress/i18n', '@tanstack/react-query' ],
output: {
format: 'iife',
globals: {
'react': 'window.BenecasterExtensions.React',
'react-dom': 'window.BenecasterExtensions.ReactDOM',
'react-dom/client': 'window.BenecasterExtensions.ReactDOMClient',
'react/jsx-runtime': 'window.BenecasterExtensions.ReactJSXRuntime',
'@wordpress/i18n': 'window.BenecasterExtensions.i18n',
'@tanstack/react-query': 'window.BenecasterExtensions.ReactQuery',
},
},
},
},
};
And enqueue the result with Benecaster’s script handle as a dependency, so Benecaster is parsed first:
add_action( 'admin_enqueue_scripts', function () {
wp_enqueue_script(
'my-addon-admin',
plugins_url( 'assets/js/admin.js', __FILE__ ),
[ 'benecaster-admin', 'wp-dom-ready', 'wp-api-fetch' ],
'1.0.0',
true
);
} );
Map react/jsx-runtime as well as react. Modern React builds use the automatic JSX transform, so externalising only react still bundles a second copy of the JSX runtime and the hooks still fail.
Map the @tanstack/react-query module, not just the client. Query hooks find their client through React context, and an add-on that bundles its own copy of the library finds no provider. queryClient is published too, for imperative calls such as invalidateQueries() from outside a component.
In the setup wizard, ReactQuery and queryClient are absent. A wizard step must not externalise @tanstack/react-query, and should fetch with window.fetch. Feature-detect with 'ReactQuery' in window.BenecasterExtensions.
Check extensionRuntimeVersion before relying on any of this. On a Benecaster version that does not publish the runtime it is undefined, and failing there with your own message is clearer than an Invalid hook call several frames deep.
Notes
window.BenecasterExtensions is the global object provided by Benecaster. registerFill is available after wp.domReady fires; use optional chaining (?.) to guard against the case where Benecaster isn’t loaded on the current page. The runtime keys above are published earlier, as Benecaster’s script is parsed, which is why your script must list benecaster-admin as a dependency.
wp.apiFetch handles WordPress nonce authentication automatically. Use it for any REST requests rather than raw fetch.
Dashboard Cards
The worked example fills BenecasterDashboardCards. It needs no PHP declaration step, because the dashboard slot already exists in Benecaster. It uses useState and useQuery, which work because the bundle is built against Benecaster’s runtime as described above.
Code
// my-addon/resources/js/admin.jsx (compiled with Benecaster's runtime as externals)
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { __ } from '@wordpress/i18n';
const MyDashboardCard = ( { showId } ) => {
const [ open, setOpen ] = useState( false );
const { data } = useQuery( {
queryKey: [ 'my-addon-stats', showId ],
queryFn: () => window.wp.apiFetch( { path: `/my-addon/v1/stats/${ showId }` } ),
} );
return (
<div className="bc-p-4 bc-border bc-rounded">
<button onClick={ () => setOpen( ! open ) }>
{ __( 'My Add-on Stats', 'my-addon' ) }
</button>
{ open && <p>{ data?.total ?? '—' }</p> }
</div>
);
};
wp.domReady( () => {
window.BenecasterExtensions?.registerFill( 'BenecasterDashboardCards', MyDashboardCard );
} );
// Named slots and their fillProps:
// BenecasterEpisodePrePublish — { episode_id, show_id }
// BenecasterEpisodeEditorTab_{id} — { episodeId, showId }
// BenecasterEpisodeEditorAfterFields — { episodeId, showId }
// BenecasterShowEditorAfterFields — { showId }
// BenecasterDashboardCards — { showId }
// BenecasterSettingsPage_{id} — no props (install-wide Settings screen)
// BenecasterShowSettingsPage_{id} — { showId } (one show's settings)
// BenecasterSubscriberDetailAfter — { subscriberId, showId }
Need this built rather than just documented? See our services →