TutorialsLive post search with Reactiph

Live post search with Reactiph

Build a real-time WordPress post search box using Reactiph, a reactive PHP component framework, wired into TAW as a block with a genuine server-side search round-trip.

This tutorial builds a post search box: a visitor types, and matching posts appear below the input without a page reload — no jQuery, no hand-rolled AJAX boilerplate, no separate JS framework. It's powered by Reactiph, a reactive PHP component framework developed alongside TAW, integrated via the reactiph/taw-bridge package.

Reactiph is a separate, actively-evolving project — not part of taw-core — installed as its own Composer dependency. It has no tagged stable release yet (dev-main only), so treat this integration as early and expect its API to keep moving.

Why not ReactiveMetaBlock?

reactiph/taw-bridge ships a ReactiveMetaBlock class that lets a Reactiph component's client-side interactivity run as real, transpiled JavaScript — no network request per click. That's the right tool for state that lives entirely in the browser (a counter, a toggle, a tab switcher).

Post search needs something ReactiveMetaBlock can't give it: a real WP_Query against your database. Reactiph's PHP→JS transpiler only understands a safe subset of PHP (arithmetic, string ops, array access, $this->method() calls) — and ReactiveMetaBlock always transpiles every method on the component it renders, with no way yet to mark one as server-only. A method that calls get_posts() will fail to transpile.

So this tutorial uses the pattern Reactiph's own docs call out for exactly this case: a component whose interactivity is a genuine RPC round-trip — a real HTTP request to a REST endpoint that runs the method on the server and returns the result — authored by hand instead of relying on ReactiveMetaBlock's automatic transpilation. You still get a real Reactiph component (same base class, same template syntax); you're just wiring its one interactive method yourself instead of asking the transpiler to.


Prerequisites

  • A working TAW Theme install (composer install + npm install done)
  • Vite dev server running (npm run dev)

Steps

Install reactiph/taw-bridge

Reactiph isn't on Packagist yet — point Composer at its GitHub repos directly, alongside the taw/core entry your theme already has.

{
    "require": {
        "reactiph/taw-bridge": "*"
    },
    "repositories": [
        { "type": "vcs", "url": "https://github.com/Relmaur/taw-core" },
        { "type": "vcs", "url": "https://github.com/Relmaur/reactiph" },
        { "type": "vcs", "url": "https://github.com/Relmaur/reactiph-wordpress-bridge" },
        { "type": "vcs", "url": "https://github.com/Relmaur/reactiph-taw-bridge" }
    ],
    "minimum-stability": "dev",
    "prefer-stable": true
}
composer update reactiph/taw-bridge --with-all-dependencies

minimum-stability: dev is required here — reactiph/reactiph only exists as dev-main today. prefer-stable: true keeps every other dependency (taw/core, PHPUnit, etc.) resolving to its normal stable release instead.

Register the RPC endpoint

Add this to inc/customizations.phpnever functions.php, which is framework-owned and overwritten on every update-theme sync. This registers POST /wp-json/reactiph/v1/rpc, the route any Reactiph component's server-bound method call goes through.

add_action('rest_api_init', function () {
    (new \Reactiph\WordPressBridge\WordPressBridge())->registerRoutes();
});

Scaffold the block folder

Blocks/PostSearch/
├── PostSearch.php               ← the MetaBlock
├── PostSearchComponent.php      ← the Reactiph component
├── PostSearchComponent.reactiph.html
├── index.php                    ← the block's template
└── style.scss

Reactiph auto-discovers PostSearchComponent.reactiph.html next to PostSearchComponent.php by reflection — no registration step needed, same convention TAW's own BlockLoader uses for a block's own class.

Write the Reactiph component

This is a real Reactiph component — a plain PHP class with public properties as state, extending BaseComponent. search() is the one method the search box calls; it does the actual WP_Query work the transpiler can't handle.

<?php

declare(strict_types=1);

namespace TAW\Blocks\PostSearch;

use Reactiph\Component\BaseComponent;

final class PostSearchComponent extends BaseComponent
{
    /** @var array<int, array{title: string, url: string}> */
    public array $results = [];

    public function search(string $query): void
    {
        $query = trim($query);

        if ($query === '') {
            $this->results = [];
            return;
        }

        $posts = get_posts([
            's'              => sanitize_text_field($query),
            'post_type'      => 'post',
            'post_status'    => 'publish',
            'posts_per_page' => 10,
        ]);

        $this->results = array_map(
            static fn (\WP_Post $post): array => [
                'title' => get_the_title($post),
                'url'   => (string) get_permalink($post),
            ],
            $posts,
        );
    }
}
<div class="post-search">
    <input
        type="search"
        id="post-search-input"
        class="post-search__input"
        placeholder="Search posts…"
        autocomplete="off"
    />
    <ul id="post-search-results" class="post-search__results"></ul>
</div>

The template only renders the initial empty state — no results yet, since nothing has been searched. The results list is populated by hand-written JavaScript after each RPC response, the same way you'd update the DOM in any hand-rolled AJAX search.

Write the block class

A plain MetaBlock — not ReactiveMetaBlock (see the callout above). getData() renders the component's initial SSR markup and hands the template everything it needs to wire up the RPC call: the endpoint URL, a REST nonce, and the component's class name.

<?php

declare(strict_types=1);

namespace TAW\Blocks\PostSearch;

use Reactiph\WordPressBridge\WordPressBridge;
use TAW\Core\Block\MetaBlock;

final class PostSearch extends MetaBlock
{
    protected string $id = 'post-search';

    protected function registerMetaboxes(): void
    {
        // No editor fields -- results come from a live WP_Query via
        // RPC, triggered entirely client-side as the visitor types.
    }

    protected function getData(int|false $postId): array
    {
        $component = new PostSearchComponent();

        return [
            'component_html'  => $component->render(),
            'component_class' => PostSearchComponent::class,
            'rpc_url'         => (new WordPressBridge())->rpcEndpointUrl(),
            'nonce'           => wp_create_nonce('wp_rest'),
        ];
    }
}
composer dump-autoload

Wire up the search box

index.php echoes the component's SSR markup, then hand-wires the input: on every keystroke (debounced 300ms), it POSTs to the RPC endpoint with the typed query as args, and rebuilds the results list from the JSON response.

<?php
// $component_html, $component_class, $rpc_url, $nonce come from getData()

echo $component_html;
?>

<script>
(function () {
    var input = document.getElementById('post-search-input');
    var results = document.getElementById('post-search-results');
    var timer = null;

    input.addEventListener('input', function () {
        clearTimeout(timer);
        var query = input.value;

        timer = setTimeout(function () {
            fetch(<?php echo wp_json_encode($rpc_url); ?>, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-WP-Nonce': <?php echo wp_json_encode($nonce); ?>,
                },
                body: JSON.stringify({
                    component: <?php echo wp_json_encode($component_class); ?>,
                    method: 'search',
                    state: {},
                    args: [query],
                }),
            })
                .then(function (res) { return res.json(); })
                .then(function (result) {
                    results.innerHTML = '';
                    result.state.results.forEach(function (post) {
                        var li = document.createElement('li');
                        var a = document.createElement('a');
                        a.href = post.url;
                        a.textContent = post.title;
                        li.appendChild(a);
                        results.appendChild(li);
                    });
                });
        }, 300);
    });
})();
</script>

X-WP-Nonce is required — WordPressBridge's RPC route rejects any request without a valid wp_rest nonce. The nonce above is generated fresh on every page load in getData(), so it's always current for that pageview.

Add styles

.post-search {
    max-width: 480px;
    margin: 0 auto;

    &__input {
        width: 100%;
        padding: 12px 16px;
        font-size: 1rem;
        border: 1px solid #d1d5db;
        border-radius: 6px;
    }

    &__results {
        list-style: none;
        margin: 12px 0 0;
        padding: 0;

        li {
            padding: 8px 0;
            border-bottom: 1px solid #e5e7eb;
        }

        a {
            color: #f97316;
            text-decoration: none;

            &:hover { text-decoration: underline; }
        }
    }
}

Render it on a page

<?php
// page-search.php

use TAW\Core\Block\BlockRegistry;

BlockRegistry::queue('post-search');
get_header();
?>

<?php BlockRegistry::render('post-search'); ?>

<?php get_footer(); ?>

Try it

Visit the page and start typing. After a short pause, matching post titles appear below the input, each linking to the real post.

Open your browser's Network tab while you type — you'll see a real POST /wp-json/reactiph/v1/rpc fire (debounced, not one per keystroke), and the response is plain JSON: {"state":{"results":[...]}}. No page reload, no admin-ajax.php, and the search logic itself — get_posts(), sanitization, the query args — is ordinary PHP you could unit test on its own.


What to build next