Skip to content

Recipe explorer: filters from a Views REST export ​

A complete example of the scaffold: a React recipe browser on the Umami demo where everything the UI shows comes from one View. Add a filter to the view and a new control appears, no React change.

How it is built ​

LayerFile
Data: View with a Better REST export displayconfig/optional/views.view.recipe_explorer.yml
Page: another display of the same view + template that mounts the componenttemplates/views/views-view--recipe-explorer--page-1.html.twig
Component (SDC)components/recipe-explorer/
Library + assetslibraryOverrides in recipe-explorer.component.yml, built to assets/recipe-explorer.{js,css}

Pages: /recipe-explorer (UI), /api/recipes (JSON).

1. The view ​

Create a view of Content (type Recipe), then:

  1. Fields: title, summary, image (through a relationship to the media entity, formatter Image URL), difficulty, preparation time, category, and Link to content with Output the URL as text enabled (gives a plain URL, not an <a>).
  2. Exposed filters: title contains (identifier search), difficulty (difficulty), category (category, taxonomy index, select), and a grouped numeric filter on preparation time (time) with items like "Up to 10 minutes".
  3. Exposed sorts: created, title, preparation time.
  4. Pager: full, 6 items.
  5. Add display Better REST export at api/recipes. Style: Better REST serializer, row: Data field with aliases (field_difficulty → difficulty, view_node → url, ...). Set the pager on this display (the REST display does not inherit it).
  6. Add display Page at recipe-explorer that only exists to host the template.

Key pieces from the exported config:

yaml
better_rest_export_1:
  display_plugin: better_rest_export
  display_options:
    path: api/recipes
    style:
      type: better_rest_resources_serializer
      options: { formats: { json: json } }
    row:
      type: data_field
      options:
        field_options:
          field_difficulty:       { alias: difficulty, raw_output: true }
          field_preparation_time: { alias: prep_time,  raw_output: true }
          view_node:              { alias: url,        raw_output: false }

Language and duplicates

Content is translated (en/es in Umami). Add the Content language = current content language filter for nodes and for the media relationship, otherwise every recipe appears once per media translation.

2. The JSON the view produces ​

GET /en/api/recipes?difficulty=easy&time=2&sort_by=title&sort_order=ASC (trimmed):

json
{
  "endpoint": {
    "path": "api/recipes",
    "args": [],
    "requested": "/en/api/recipes"
  },
  "pager": {
    "active": true,
    "current_page": 0,
    "total_items": "4",
    "items_per_page": 6,
    "total_pages": 1
  },
  "exposed_filters": [
    {
      "label": "Search",
      "description": "Words in the recipe title",
      "identifier": "search",
      "submitted_values": [],
      "options": []
    },
    {
      "label": "Difficulty",
      "identifier": "difficulty",
      "submitted_values": "easy",
      "options": {
        "easy": "Easy",
        "medium": "Medium",
        "hard": "Hard"
      }
    },
    {
      "label": "Category",
      "identifier": "category",
      "submitted_values": [],
      "options": {
        "29": "Accompaniments",
        "30": "Desserts",
        "33": "Starters",
        "31": "Main courses",
        "32": "Snacks"
      }
    },
    {
      "label": "Preparation time",
      "identifier": "time",
      "group_items": {
        "1": {
          "title": "Up to 10 minutes",
          "operator": "<="
        },
        "2": {
          "title": "Up to 20 minutes",
          "operator": "<="
        },
        "3": {
          "title": "Over 20 minutes",
          "operator": ">"
        }
      },
      "options": {
        "1": "Up to 10 minutes",
        "2": "Up to 20 minutes",
        "3": "Over 20 minutes"
      }
    }
  ],
  "exposed_sorts": [
    {
      "label": "Newest",
      "field_identifier": "created"
    },
    {
      "label": "Title",
      "field_identifier": "title"
    },
    {
      "label": "Preparation time",
      "field_identifier": "field_preparation_time_value"
    }
  ],
  "rows": [
    {
      "title": "Fiery chili sauce",
      "summary": "<p>A rich and fiery chili sauce. Take care when handling chili peppers. And serve sparingly!</p>\n",
      "image": "/sites/default/files/styles/medium_3_2_600x400/public/chili-sauce-umami.jpg.avif?itok=aQpGwsvL",
      "difficulty": "easy",
      "prep_time": 10,
      "category": "Accompaniments",
      "url": "/en/recipes/fiery-chili-sauce"
    }
  ]
}

The module adds what a UI needs next to rows:

KeyMeaning
exposed_filters[]one entry per exposed filter: identifier (= query param), label, description, options for selects or group_items for grouped filters, submitted_values
exposed_sorts[]field_identifier and label. Pass `sort_by=<field_identifier>&sort_order=ASC
pagercurrent_page, total_pages, total_items, items_per_page. Pass page=<n>
rows[]the aliased fields

3. The React side ​

The control for each filter is chosen from its shape:

jsx
const filterType = (filter) => {
  if (filter.group_items && Object.keys(filter.group_items).length) return 'group';   // grouped filter → select of titles
  if (filter.options && Object.keys(filter.options).length) return 'select';          // select of options
  return 'text';                                                                       // no options → search input
};

State is one flat object that is exactly the query string:

jsx
const [query, setQuery] = useState(readUrl);          // initial state from window.location.search
useEffect(() => {
  apiClient(endpoint, { ...query }).then(setData);    // null/'' values are dropped by apiClient
  window.history.replaceState(null, '', location.pathname + '?' + new URLSearchParams(query)); // shareable URLs
}, [endpoint, query]);
  • Changing a filter resets page, changing page keeps the filters.
  • The text input is debounced (300 ms), responses that arrive out of order are ignored.
  • Accessibility: labelled controls, role="search", aria-live result count, aria-busy while loading, aria-current on the pager.
  • Summaries come from Drupal's text_trimmed formatter, so they are filtered markup and are rendered with dangerouslySetInnerHTML.

4. Mounting it from the View page ​

templates/views/views-view--recipe-explorer--page-1.html.twig replaces the Views output for the page display and includes the SDC with the REST URL of the sibling display:

twig
{% include 'react_scaffold:recipe-explorer' with {
  endpoint: path('view.recipe_explorer.better_rest_export_1'),
  heading: 'Recipes'|t,
} only %}

Full source of the component ​

The files below are imported from the repository, so they are always the real code.

yaml
$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: Recipe Explorer
status: stable
description: 'A React-based recipe browser: exposed filters, sorting and pager are driven by a views_better_rest endpoint.'
group: Content
props:
  type: object
  properties:
    endpoint:
      type: string
      title: Endpoint
      description: 'Better REST export endpoint of the recipe_explorer view'
      default: '/api/recipes'
      examples:
        - '/api/recipes'
    heading:
      type: string
      title: Heading
      description: 'Accessible heading of the results region'
      default: 'Recipes'
      examples:
        - 'Find a recipe'
  required:
    - endpoint
libraryOverrides:
  js:
    ../../assets/recipe-explorer.js: { attributes: { type: module } }
  css:
    theme:
      ../../assets/recipe-explorer.css: { }
  dependencies:
    - react_scaffold/react
    - react_scaffold/react-api-client
twig
<div {{ attributes.addClass('recipe-explorer').setAttribute('data-endpoint', endpoint).setAttribute('data-heading', heading|default('Recipes')) }}>
  <noscript>{{ 'The recipe explorer needs JavaScript.'|t }}</noscript>
</div>
jsx
import { RecipeExplorer } from './RecipeExplorer';
import './recipe-explorer.scss';
import { createRoot } from 'react-dom/client';

(function (Drupal, once) {
  const attachRecipeExplorer = (element) => {
    createRoot(element).render(
      <RecipeExplorer endpoint={element.dataset.endpoint} heading={element.dataset.heading}/>
    );
  };

  Drupal.behaviors.recipeExplorer = {
    attach(context) {
      once('react', '.recipe-explorer', context)
        .forEach((element) => executeWhenVisible(element, attachRecipeExplorer, 'recipe-explorer'));
    },
  };
})(Drupal, once);
jsx
import { useEffect, useMemo, useRef, useState } from 'react';

const DEBOUNCE = 300;
const RESERVED = ['page', 'sort_by', 'sort_order'];

// Read the initial state from the page URL so a filtered view can be shared.
const readUrl = () => Object.fromEntries(new URLSearchParams(window.location.search));

// Text filters have no options, select filters have `options`, grouped filters
// (e.g. numeric ranges) have `group_items`.
const filterType = (filter) => {
  if (filter.group_items && Object.keys(filter.group_items).length) return 'group';
  if (filter.options && Object.keys(filter.options).length) return 'select';
  return 'text';
};

const selectOptions = (filter) => {
  if (filterType(filter) === 'group') {
    return Object.entries(filter.group_items).map(([value, item]) => ({ value, label: item.title }));
  }
  return Object.entries(filter.options).map(([value, label]) => ({ value, label }));
};

const Card = ({ recipe }) => (
  <li className="recipe-explorer__card">
    <a className="recipe-explorer__link" href={recipe.url}>
      <span className="recipe-explorer__media">
        {recipe.image && <img src={recipe.image} alt="" loading="lazy" width="600" height="400"/>}
        {recipe.category && <span className="recipe-explorer__category">{recipe.category}</span>}
      </span>
      <span className="recipe-explorer__body">
        <span className="recipe-explorer__title">{recipe.title}</span>
        <span className="recipe-explorer__meta">
          {recipe.difficulty && (
            <span className={'recipe-explorer__badge recipe-explorer__badge--' + recipe.difficulty}>
              {recipe.difficulty}
            </span>
          )}
          {recipe.prep_time != null && (
            <span className="recipe-explorer__time">{Drupal.t('@min min', { '@min': recipe.prep_time })}</span>
          )}
        </span>
        {/* Summary is rendered by Drupal (text_trimmed formatter), so it is already filtered markup. */}
        <span className="recipe-explorer__summary" dangerouslySetInnerHTML={{ __html: recipe.summary }}/>
      </span>
    </a>
  </li>
);

const Pager = ({ pager, onPage }) => {
  if (!pager?.total_pages || pager.total_pages < 2) return null;
  const current = pager.current_page;
  return (
    <nav className="recipe-explorer__pager" aria-label={Drupal.t('Pagination')}>
      <button type="button" disabled={current === 0} onClick={() => onPage(current - 1)}>
        {pager.options?.tags?.previous || Drupal.t('Previous')}
      </button>
      {Array.from({ length: pager.total_pages }, (_, i) => (
        <button
          type="button"
          key={i}
          aria-current={i === current ? 'page' : undefined}
          aria-label={Drupal.t('Page @n', { '@n': i + 1 })}
          className={i === current ? 'is-active' : ''}
          onClick={() => onPage(i)}
        >{i + 1}</button>
      ))}
      <button type="button" disabled={current >= pager.total_pages - 1} onClick={() => onPage(current + 1)}>
        {pager.options?.tags?.next || Drupal.t('Next')}
      </button>
    </nav>
  );
};

export const RecipeExplorer = ({ endpoint, heading }) => {
  const [data, setData] = useState(null);
  const [query, setQuery] = useState(readUrl);
  const [text, setText] = useState(() => ({}));
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(false);
  const requestId = useRef(0);

  const update = (patch) => setQuery((q) => {
    const next = { ...q, ...patch };
    // Anything but a page change goes back to the first page.
    if (!('page' in patch)) delete next.page;
    Object.keys(next).forEach((k) => (next[k] === '' || next[k] == null) && delete next[k]);
    return next;
  });

  // Fetch whenever the query changes; ignore out-of-order responses.
  useEffect(() => {
    const id = ++requestId.current;
    setLoading(true);
    apiClient(endpoint, { ...query })
      .then((response) => {
        if (id !== requestId.current) return;
        setData(response);
        setError(false);
        setLoading(false);
      })
      .catch(() => id === requestId.current && (setError(true), setLoading(false)));
    const qs = new URLSearchParams(query).toString();
    window.history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : ''));
  }, [endpoint, query]);

  // Debounce free text inputs so we do not request on every keystroke.
  useEffect(() => {
    const timers = Object.entries(text).map(([key, value]) =>
      setTimeout(() => query[key] !== value && update({ [key]: value }), DEBOUNCE));
    return () => timers.forEach(clearTimeout);
  }, [text]);

  const filters = data?.exposed_filters || [];
  const sorts = data?.exposed_sorts || [];
  const sortBy = query.sort_by || sorts[0]?.field_identifier || '';
  const sortOrder = query.sort_order || (sortBy === 'created' ? 'DESC' : 'ASC');
  const active = useMemo(
    () => Object.keys(query).filter((k) => !RESERVED.includes(k)).length > 0, [query]);

  const reset = () => { setText({}); setQuery({}); };

  if (!data) {
    return <p className="recipe-explorer__status" role="status">{error ? Drupal.t('Error loading recipes') : Drupal.t('Loading…')}</p>;
  }

  return (
    <section className={'recipe-explorer__inner' + (loading ? ' is-loading' : '')} aria-busy={loading}>
      <form className="recipe-explorer__filters" role="search" onSubmit={(e) => e.preventDefault()}>
        {filters.map((filter) => {
          const type = filterType(filter);
          const id = 'recipe-explorer-' + filter.identifier;
          return (
            <div className="recipe-explorer__field" key={filter.identifier}>
              <label htmlFor={id}>{filter.label}</label>
              {type === 'text' ? (
                <input
                  id={id}
                  type="search"
                  placeholder={filter.description}
                  value={text[filter.identifier] ?? query[filter.identifier] ?? ''}
                  onChange={(e) => setText({ ...text, [filter.identifier]: e.target.value })}
                />
              ) : (
                <select id={id} value={query[filter.identifier] || ''}
                        onChange={(e) => update({ [filter.identifier]: e.target.value })}>
                  <option value="">{Drupal.t('Any')}</option>
                  {selectOptions(filter).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
                </select>
              )}
            </div>
          );
        })}
        {sorts.length > 0 && (
          <div className="recipe-explorer__field">
            <label htmlFor="recipe-explorer-sort">{Drupal.t('Sort by')}</label>
            <div className="recipe-explorer__sort">
              <select id="recipe-explorer-sort" value={sortBy}
                      onChange={(e) => update({ sort_by: e.target.value, sort_order: undefined })}>
                {sorts.map((s) => <option key={s.field_identifier} value={s.field_identifier}>{s.label}</option>)}
              </select>
              <button type="button" className="recipe-explorer__order"
                      aria-label={sortOrder === 'ASC' ? Drupal.t('Ascending') : Drupal.t('Descending')}
                      onClick={() => update({ sort_by: sortBy, sort_order: sortOrder === 'ASC' ? 'DESC' : 'ASC' })}>
                {sortOrder === 'ASC' ? '↑' : '↓'}
              </button>
            </div>
          </div>
        )}
        {active && <button type="button" className="recipe-explorer__reset" onClick={reset}>{Drupal.t('Reset')}</button>}
      </form>

      <h2 className="recipe-explorer__heading">{heading}</h2>
      <p className="recipe-explorer__count" role="status" aria-live="polite">
        {Drupal.formatPlural(Number(data.pager.total_items), '1 recipe found', '@count recipes found')}
      </p>

      {error && <p className="recipe-explorer__status">{Drupal.t('Error loading recipes')}</p>}
      {data.rows.length === 0 && !error && (
        <p className="recipe-explorer__status">{Drupal.t('No recipes match these filters.')}</p>
      )}
      <ul className="recipe-explorer__grid">
        {data.rows.map((recipe) => <Card key={recipe.url} recipe={recipe}/>)}
      </ul>
      <Pager pager={data.pager} onPage={(page) => update({ page })}/>
    </section>
  );
};
scss
// Palette follows the Umami demo theme.
$green: #008068;
$green-light: #e6eee0;
$pink: #fcece7;
$red: #da3c13;
$grey: #767775;

.recipe-explorer {
  margin: 2rem 0;
  font-family: "Source Sans Pro", Verdana, sans-serif;

  &__filters {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
    gap: 1rem;
    align-items: end;
    padding: 1.25rem;
    background: $green-light;
    border-radius: .5rem;
  }

  &__field {
    display: flex;
    flex-direction: column;
    gap: .35rem;

    label {
      font-size: .85rem;
      font-weight: 600;
      text-transform: uppercase;
      letter-spacing: .04em;
    }

    input,
    select {
      height: 2.5rem;
      padding: 0 .75rem;
      border: 1px solid #b8c4b0;
      border-radius: .25rem;
      background: #fff;
      font: inherit;
      min-width: 0;
    }

    input:focus-visible,
    select:focus-visible,
    button:focus-visible {
      outline: 3px solid #0094f0;
      outline-offset: 1px;
    }
  }

  &__sort {
    display: flex;
    gap: .35rem;

    select {
      flex: 1;
    }
  }

  &__order,
  &__reset,
  &__pager button {
    height: 2.5rem;
    min-width: 2.5rem;
    padding: 0 .9rem;
    border: 1px solid $green;
    border-radius: .25rem;
    background: #fff;
    color: $green;
    font: inherit;
    font-weight: 600;
    cursor: pointer;

    &:hover:not(:disabled) {
      background: $green;
      color: #fff;
    }

    &:disabled {
      opacity: .4;
      cursor: default;
    }
  }

  &__heading {
    margin: 2rem 0 .25rem;
    font-family: "Scope One", Georgia, serif;
    font-weight: 400;
  }

  &__count,
  &__status {
    margin: 0 0 1rem;
    color: $grey;
  }

  &__grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
    gap: 1.5rem;
    margin: 0;
    padding: 0;
    list-style: none;
    transition: opacity .15s;
  }

  .is-loading &__grid {
    opacity: .5;
  }

  &__card {
    background: #fff;
    border-radius: .5rem;
    box-shadow: 0 1px 4px rgba(0, 0, 0, .15);
    overflow: hidden;
    transition: box-shadow .15s, transform .15s;

    &:hover,
    &:focus-within {
      box-shadow: 0 6px 16px rgba(0, 0, 0, .2);
      transform: translateY(-2px);
    }
  }

  &__link {
    display: flex;
    flex-direction: column;
    height: 100%;
    color: inherit;
    text-decoration: none;
  }

  &__media {
    position: relative;
    display: block;
    aspect-ratio: 3 / 2;
    background: $pink;

    img {
      width: 100%;
      height: 100%;
      object-fit: cover;
      display: block;
    }
  }

  &__category {
    position: absolute;
    left: .75rem;
    bottom: .75rem;
    padding: .15rem .6rem;
    background: $green;
    color: #fff;
    font-size: .8rem;
    border-radius: 1rem;
  }

  &__body {
    display: flex;
    flex-direction: column;
    gap: .5rem;
    padding: 1rem;
  }

  &__title {
    font-family: "Scope One", Georgia, serif;
    font-size: 1.2rem;
    line-height: 1.3;
  }

  &__meta {
    display: flex;
    gap: .75rem;
    align-items: center;
    font-size: .9rem;
    color: $grey;
  }

  &__badge {
    padding: .1rem .55rem;
    border-radius: 1rem;
    background: $green-light;
    color: #025c4b;
    text-transform: capitalize;

    &--medium {
      background: #fdf0c8;
      color: #7a5600;
    }

    &--hard {
      background: $pink;
      color: $red;
    }
  }

  &__summary {
    font-size: .95rem;
    line-height: 1.5;

    p {
      margin: 0;
    }
  }

  &__pager {
    display: flex;
    flex-wrap: wrap;
    gap: .4rem;
    justify-content: center;
    margin-top: 2rem;

    .is-active {
      background: $green;
      color: #fff;
    }
  }
}

@media (prefers-reduced-motion: reduce) {
  .recipe-explorer__card,
  .recipe-explorer__grid {
    transition: none;
  }
}

Adapting it ​

  • Another entity type: change the base table and fields, keep the row aliases you use in Card.
  • Another filter: add an exposed filter to the view. Selects, text and grouped filters show up automatically. A widget the component does not know (date range, multi-select) needs a new branch in filterType.
  • Routing without Views page: put the component in any template or block, see Twig.