@validdata.de/react-content-slots

A simple content slot system

Usage no npm install needed!

<script type="module">
  import validdataDeReactContentSlots from 'https://cdn.skypack.dev/@validdata.de/react-content-slots';
</script>

README

@validdata.de/react-content-slots

Place slots in your layout and define their content (rendered elements, data...) somewhere else in the component tree.

Use cases

Often in React applications you'll want to define the content of a certain section on the page at a completely different place in the component tree. For example, consider an application that shows either a list of products or the details of a single product.

That is, when showing the list of products, the component tree may look like this:

      Layout
       /  \
Products   Toolbar
  |
List

And when a product is selected from the list, the Products component render a Details component as its child instead:

      Layout
       /  \
Products   Toolbar
  |
Details

Here's the pseudo-code of such an app:

const Layout = () => (
    <>
        <Toolbar/>
        <Product/>
    </>
)

const Toolbar = () => (
    <div>...</div>
)

const Products = () => {
    const [view, setView] = useState('list');

    return <>
        <button onClick={() => setView('list')}>To List</button>
        <button onClick={() => setView('details')}>To Details</button>
        { view === 'list' ? <List/> : <Details/> }
    </>
}

const List = () => ( <table>...</table> )
const Details = () => ( <section><h2>Details</h2>...</section> )

Now imagine you'd want the content of Toolbar to change between the two cases. The information which view is currently displayed (List or Details) is stored in Products. Layout - the component responsible for rendering Toolbar does not know about this and thus cannot easily fill in the content of Toolbar.

That's where react-content-slots comes into play. With it you can define 'slots' that hold certain content (e.g. React elements or other data). Wherever needed the content of a slot can be accessed and used appropriately (e.g. by rendering it). At other places you can inject the content into the slot. You can define the slot content at multiple places in the component tree and the definition the lowest in the tree will be the one that is actually used.

What would this look like in our example? You need four things

  1. A definition of the slot, similar to the definition of a context in React: createSlot
  2. One of two ways define the content of the slot: either the useSlot hook or the SlotContent component.
  3. One of two ways to access the content of the slot: either the useSlotContent hook or the SlotOutlet component
  4. A SlotProvider which is placed at a common ancestor of any component that uses useSlot/SlotContent/useSlotContent/SlotOutlet
// 1. The slot definition
const ToolbarSlot = createSlot();

// 2. A place where the slot content is used
const Toolbar = () => (	
    <div><SlotOutlet slot={ToolbarSlot} /></div>
)

const Products = () => {
    const [view, setView] = useState('list');

    return <React.Fragment>
        <button onClick={() => setView('list')}>To List</button>
        <button onClick={() => setView('details')}>To Details</button>
        { view === 'list' ? <List/> : <Details/> }
    </React.Fragment>
}


// 3. A definition of the slot content
const List = () => (
    <React.Fragment>
        <SlotContent slot={ToolbarSlot}>
            <button>Add</button>
            <input placeholder="Search..." />
        </SlotContent>
        <table>...</table>
    </React.Fragment>
)

// 3 (contd., optional). Maybe another definition of the slot content -->
const Details = () => (
    <section>
        <SlotContent slot={ToolbarSlot}>
            <button>Delete</button>
            <button>Print</button>
        </SlotContent>
        <h2>Details</h2>
        ...
    </section>
)

// 4. A SlotProvider tying everything together
const Layout = () => (	
    <SlotProvider slot={ToolbarSlot}>
        <Toolbar/>
        <Products/>
    </SlotProvider>
)

Caveats

  • Never use the useSlotContent hook at an ancestor of any component that uses SlotContent or useSlot for the same slot. Doing so will cause an infinite rendering loop inside React. Workarounds:

    • If possible, use SlotOutlet. While inside it uses useSlotContent, being in a distinct component breaks the ancestral line to SlotContent/useSlot
    • Otherwise, extract the part that uses useSlotContent into a component and render that component instead. This will break the ancestral line between useSlotContent and SlotContent/useSlot

    The reason for this behavior is easy to explain: when useSlot is called (which SlotContent uses internally) it may potentially update the current value of slot.This will all components that use useSlotContent to re-render. If the component that called useSlot is a descendant of the useSlotContent use it may be re-rendered itself, causing a call to useSlot that may update the slot value, which leads to a re-render of the useSlotContent component etc.

  • Never render SlotContent conditionally. That is, if you render it once, make sure to always render it. Treat it like a hook in this regard. Conditionally rendering SlotContent will mess up the content stack. If you need to conditionally (not) overwrite parent content, use the disable prop of SlotContent.

API

createSlot(defaultValue?)

Returns a new slot. Optionally, the function accepts a default value. If no explicit default value is provided, null is used.

NB for TypeScript users: you can (and should) provide a type to createSlot to determine the type of the content. If no type is provided, ReactNode is assumed. If a default value is provided the type can be inferred. However, if the provided default value is a React element, you should still set ReactNode as the type argument, otherwise it's easy to run into type issues when using SlotContent.

Parameters

  • defaultValue (optional): the default value for the slot when no other content has been defined.

Returns

  • A slot variable that is used by most of the other functions and components in this library.

Examples:

const title = createSlot('Default title');
const content = createSlot();
const notRecommended = createSlot(<p>Default</p>);
const recommended = createSlot<ReactNode>(<p>Default</p>);

<SlotProvider>

A context provided that has to be placed at a common ancestor of all components that use useSlot or useSlotContent directly or indirectly. For each slot, you should have at least one SlotProvider in your app (or use SlotProviders for brevity). SlotProvider can be nested like all context providers to redefine the slot system at a specific subtree of the component tree.

Props

  • slot: a slot variable created with createSlot
  • children: the subtree that uses the provided slot

Example

import {createSlot, SlotProvider} from '@validdata.de/react-content-slots';

export const TitleSlot = createSlot('Default title');

const App = () => {
    return (
        <SlotProvider slot={TitleSlot}>
            ... Rest of the app
        </SlotProvider>
    )
}

<SlotProviders>

A convenience component that allows multiple slots to be provided. This exists only to avoid nesting of a bunch of SlotProviders when multiple slots are used in an application.

Props

  • slots: an array of slot variables created with createSlot. For performance reasons this should be constant or at least memoized somehow.
  • children: the subtree that uses the provided slot
import {createSlot, SlotProviders} from '@validdata.de/react-content-slots';

export const TitleSlot = createSlot('Default title');
export const ContentSlot = createSlot();

const allSlots = [TitleSlot, ContentSlot];

const App = () => {
    return (
        <SlotProviders slots={allSlots}>
            ... Rest of the app
        </SlotProviders>
    )
}

useSlot(slot, content, disable?)

Set the content of a slot.

Parameters

  • slot: the slot for which the content should be defined
  • content: the new content
  • disable (optional): if true, the content will not actually be used. Instead, the parent content or the default content will be used. Useful if you only want to conditionally overwrite parent content.

Example

const MyPage = () => {
    useContent(AppBarSlot, <p>This content is captured here and used somewhere </p>);
    useContent(TitleSlot, "My awesome page");
    
    return (...)
}

<SlotContent>

Set the content of a slot. If you prefer the "thinking in components" approach, you can use this component instead of useSlot. Uses useSlot internally.

Props

  • slot: the slot for which the content should be defined
  • content or children: the new content. If the content prop is present, it will be used. Otherwise, the children are used as the content.
  • disable (optional): if true, the content will not actually be used. Instead, the parent content or the default content will be used. Useful if you only want to conditionally overwrite parent content.

Example

const MyPage = () => {
    return (
        <div>
            <p>Normal page content</p>
            <SlotContent slot={AppBarSlot}>
                <p>This content is captured here and used somewhere else.</p>
            </SlotContent>

            <SlotContent slot={TitleSlot} content="My awesome page"/>

            <SlotContent slot={OtheSlot} disable={someCondition}>
                <p>This content will not overwrite its parent if "someCondition"
                    is true. </p>
            </SlotContent>
        </div>
    )
}

useSlotContent(slot)

Return the current content of a slot.

Parameters

  • slot: the slot for which the current content should be returned

Returns

An object with following fields:

{
    content,
    id,
    version
}
  • content: the content set by the deepest component in the tree
  • id: an id to the current place of the slot on the content stack. The id does not change when the content change, but it changes if a different source (a different component using SlotContent/useSlot) is used for the content. Note that when the same component unmounts and later mounts again, the id associated with the content provided by this componen will be differnt.
  • version: the number of times the content for the current id has changed

<SlotOutlet>

Renders the current content of a slot as its children. Uses useSlotContent internally.

Props

  • slot: the slot for which the current content should be rendered
  • children (optional): if ommitted, the slot content itself is rendered as the children of SlotOutlet. Note that in that case the content should be compatible with ReactNode, otherwise you will get runtime errors. If provided, children needs to be a function that accepts as an argument the object returned by useSlotContent (see above).

Examples


const MyPage = () => {
    return <>
        <SlotContent slot={AppBarSlot}/>

        <SlotContent slot={TitleSlot}>
            {({id, version, content}) => (
                <p>
                    The current title is {content}. 
                    [
                        id: {id}, 
                        version: {version}
                    ]
                </p>
            )}
        </SlotContent>
    </>
}