Skip to content

Enable Async

Phui does not own an event loop — frame() is just a function you call, so a TUI can run inside whatever loop you already have.

Overview

Schedule frame() on a periodic timer in Revolt, ReactPHP or any loop, and Phui renders one frame per call, owning no loop itself. Its async runs on PHP fibers — the same mechanism those loops use — so a handler can await() work on your loop directly, no bridging code. Data arriving from outside Phui still needs wrapping so the next frame picks it up, and everything stays single-threaded.

Run frame() on your loop

Start the screen, tick frame() at your frame rate, and stop when the app quits. The shape is the same on any loop.

Drive from Revolt

php
use Phui\Exceptions\QuitException;
use Phui\Screen;
use Revolt\EventLoop;

$screen = Screen::mount(App::class)->start();

EventLoop::repeat(1 / 60, function (string $timerId) use ($screen) {
    try {
        $screen->frame();
    } catch (QuitException) {
        EventLoop::cancel($timerId);
    }
});

EventLoop::run();

$screen->stop();

Drive from ReactPHP

The same shape on ReactPHP's loop:

php
use Phui\Exceptions\QuitException;
use Phui\Screen;
use React\EventLoop\Loop;

$screen = Screen::mount(App::class)->start();

Loop::addPeriodicTimer(1 / 60, function ($timer) use ($screen) {
    try {
        $screen->frame();
    } catch (QuitException) {
        Loop::cancelTimer($timer);
        Loop::stop();
    }
});

Loop::run();

$screen->stop();

Handle async state

The waiting is your async library's job — an await(), ideally fiber-based like Amp's Future::await(). Phui runs inside those same fibers, so awaiting never blocks the render. It does leave a tracking gap, though: Phui only sees state changes made in code it drives, so anything that resolves outside a handler needs $this->async->track() to trigger a rebuild.

Await inside a handler

When a handler, timer or lifecycle method calls that await(), Phui doesn't block on it: the frame completes while the fiber is suspended, the UI keeps rendering, and the loop resumes your handler between frames — so awaiting your loop's work is the simplest way to load data on a keypress:

php
Binding::keypress(Keypress::r, function () {
    $this->status = 'loading…';
    $this->rows = $this->get(Api::class)->fetch()->await();
    $this->status = 'done';
});

State written before the await renders immediately; state written after the resume lands on the next frame. The same works in onMount to load initial data behind a spinner.

Don't await() in onRebuild: state written after the resume re-dirties the component, which rebuilds and runs onRebuild again — an endless loop. Fetch in onMount or a handler instead.

While a handler is suspended

A few things hold between the suspend and the resume:

  • A suspended handler counts as handled, so the event stops bubbling straight away — even though the handler hasn't finished.
  • If the component unmounts before the work resolves, the rest of the handler still runs, but its state writes are ignored.
  • A complete() or an exception after the resume surfaces on the next frame, exactly as if it had run synchronously.
  • A scheduler tick that calls frame() while another frame is still in flight is dropped, so a frame never re-enters mid-run.

Update from an external callback

When work resolves through a callback instead of an await() — a promise's then(), a socket's data event — Phui isn't driving that code, so it misses the state change. Wrap the callback in $this->async->track() and it won't:

php
public function onMount(): void
{
    $this->get(TaskStore::class)->onData($this->async->track(function () {
        $this->latestValue = $this->get(TaskStore::class)->read();
    }));
}

track() returns a closure to hand to the external system; when it fires, your callback runs inside Phui's change tracking and the next frame reflects it.

Stay single-threaded

Keep frame() and all component code in one thread and fiber context. Phui's state tracking assumes single-threaded access — interleaved async callbacks are fine, parallelism is not.

Released under the MIT License.