Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 256 additions & 0 deletions resources/views/docs/mobile/4/digging-deeper/async-tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
---
title: Async Tasks
order: 240
---

## Overview

Some work is too slow to do while the user waits — building a report, resizing an image, hitting a slow API.
If you run it inline and it takes too long, your user's screen freezes because the UI thread is busy, so
nothing re-renders until the work finishes and the user can't do anything about it.

Async tasks move that work onto a **separate PHP thread** and hand you the result back on the UI thread, where
you can update state and let the screen re-render:

```php
use Native\Mobile\AsyncTask;

public function generateReport(): void
{
$this->generating = true; // paints immediately — spinner shows

AsyncTask::dispatch(static function () {
return ExpensiveReport::build()->toArray();
})->finished(function (array $report) {
$this->report = $report;
$this->generating = false;
});
}
```

`dispatch()` returns straight away. Your handler finishes, the screen re-renders with the spinner, and the
task runs on its own PHP interpreter. When it completes, `finished()` fires **on the UI thread**, bound to your
live component — so `$this->report = ...` works exactly like it does in any other handler, and the screen
updates.

<aside>

Async tasks are **not** queued jobs. They **start immediately**, run concurrently, and never touch your database or
the queue (unless you tell them to). If you want durable, retryable background work that survives an app restart,
use [Queues](queues) instead.

</aside>

## The work closure must be static

The work runs in a **different PHP thread** with its own memory. It cannot see `$this`, your component's
properties, or anything else from the dispatching thread — so the closure must be declared `static`:

```php
// ✅ Static — nothing captured from the component
AsyncTask::dispatch(static fn () => Report::build());

// ❌ Throws InvalidArgumentException at dispatch time
AsyncTask::dispatch(fn () => Report::build($this->month));
```

A non-static closure throws immediately, in your handler, where you can see it — not silently in a background
log.

Pass data in by capturing plain serializable values with `use` or a shorthand closure:

```php
$month = $this->month;

AsyncTask::dispatch(static fn () => Report::build($month));
```

Everything the closure captures must be serializable. Resources, PDO handles, and open file pointers can't
cross the thread boundary and will throw.

## Handling results

### `finished()`

When the background thread is done, the `finished` callback fires:

```php
AsyncTask::dispatch(static fn () => Http::get('https://api.example.com/stats')->json())
->finished(function (array $stats) {
$this->stats = $stats;
});
```

The result travels back as JSON, so return **scalars and arrays**. For anything large — an image, a PDF, a big
export — write it to disk in the task and return the path:

```php
AsyncTask::dispatch(static function () {
$path = storage_path('app/report-'.now()->timestamp.'.pdf');
Report::build()->save($path);

return $path; // ✅ return a path, not the file contents
})->finished(fn (string $path) => $this->reportPath = $path);
```

### `failed()`

If the task throws and exception, `failed()` receives an `AsyncTaskException` carrying the original message and class:

```php
AsyncTask::dispatch(static fn () => Http::get($url)->json())
->finished(function (array $data) {
$this->data = $data;
$this->loading = false;
})
->failed(function (\Throwable $e) {
$this->error = $e->getMessage();
$this->loading = false;
});
```

The original exception object can't cross threads, so you get a stand-in. `getMessage()` returns the original
message; `originalClass()` gives you the class name it was thrown as.

<aside>

Async tasks run **once**. There's no automatic retry. If a task should retry, handle it in `failed()` or reach
for a queued job.

</aside>

## Callbacks are scoped to the screen

By default, `finished()` and `failed()` callbacks only fire if the screen that dispatched them is **still the one
on top**. If the user navigated away before the task completed, the callback is dropped.

That's deliberate: the callback is bound to a live component instance so it can mutate state, and firing it
against a screen the user has left would update something nobody is looking at, or worse, something in a
completely different component.

For tasks whose results matter regardless of where the user is — e.g. a background upload feeding a
status bar, a sync that refreshes a badge — use `shared()`:

```php
AsyncTask::dispatch(static fn () => Sync::run())
->shared('sync-complete');
```

`shared()` delivers the result as a **named event** instead of a scoped callback. Any active screen can pick it
up with `#[On]`:

```php
use Native\Mobile\Attributes\On;

#[On('sync-complete')]
public function syncComplete($event): void
{
$this->lastSync = $event->result;
}
```

The event payload carries `id`, a `status` of `finished` or `failed`, and either `result` or the failure
details.

## Running several at once

Tasks run on a small pool of background PHP contexts, so several can be in flight together:

```php
public function loadDashboard(): void
{
AsyncTask::dispatch(static fn () => Stats::revenue())
->finished(fn ($r) => $this->revenue = $r);

AsyncTask::dispatch(static fn () => Stats::orders())
->finished(fn ($o) => $this->orders = $o);
}
```

Each callback fires as its own task completes — there's no ordering guarantee between them. When more tasks are
dispatched than the pool has slots for, the extras queue up and run as slots free up. This helps to limit
overall memory consumption.

## Task classes

For anything you'd rather not write inline, extend `AsyncTask` and put the work in `handle()`:

```php
namespace App\Async;

use Native\Mobile\AsyncTask;

class BuildReport extends AsyncTask
{
public function handle(int $month): array
{
return Report::forMonth($month)->toArray();
}
}
```

Dispatch it with its arguments — they're passed to `handle()` in the background context:

```php
use App\Async\BuildReport;

BuildReport::dispatch($this->month)
->finished(fn (array $report) => $this->report = $report);
```

The arguments must be serializable, same as a closure's captures.

## Testing

`AsyncTask::fake()` runs tasks **inline and synchronously**, so your `finished()` and `failed()` callbacks fire
during the test with no threads involved:

```php
use Native\Mobile\AsyncTask;

it('loads the report', function () {
AsyncTask::fake();

Native::test(ReportScreen::class)
->tap('generateReport')
->assertSee('Revenue');
});
```

The fake also records every dispatch:

```php
$fake = AsyncTask::fake();

Native::test(ReportScreen::class)->tap('generateReport');

$fake->assertDispatched()
->assertDispatchedTimes(1);
```

Available assertions: `assertDispatched()` (optionally with a filter closure), `assertNotDispatched()`,
`assertDispatchedTimes()`, and `assertShared('alias')`.

## How It Works

When you dispatch a task, the work closure is serialized and handed to a background PHP context — a full PHP
interpreter on its own thread, with its own memory, booted once and reused. Your handler returns immediately and
the screen re-renders.

When the task finishes, the background context sends the result through the same native event channel that
that NativePHP uses to wakes your screen's runloop, which then resolves the matching callback, binds it
to the live component, and runs it. And then your component re-renders.

This is why the work closure is isolated but the callbacks aren't: the work runs on the background thread, and
the callbacks run on your UI thread, in your component.

## Remember!

- The work closure must be `static` and everything it captures must be serializable.
- Results round-trip as JSON — return scalars and arrays; pass large data by file path or cache key.
- Tasks run once, with no automatic retry.
- Scoped callbacks are dropped if the user navigates away from the screen that started the work;
use `shared()` when the result matters regardless of what screen the user moves to.
- Async tasks don't survive the app being killed. For durable background work, use [Queues](queues).
- Device APIs that need the UI (camera, dialogs, biometrics) don't belong inside async tasks. Fetch and compute
in the task; drive UI from the callbacks.
Loading