Renderless draft saves
This editor explicitly saves an article draft through CakePHP without rebuilding the form after each successful action. Alpine counters display accepted state; PHP counters show the values from the most recent render.
Complete installation first. Reuse the form_articles schema and FormArticlesTable from the article form. The demo uses TestApp; change class namespaces to App in your application. Configure a current editor identity through your application's authentication middleware and adapt authorize() to its identity type. Run the demo at /renderless.
Files
<?php
declare(strict_types=1);
namespace TestApp\Island;
use Cake\Datasource\EntityInterface;
use Cake\Http\Exception\ForbiddenException;
use Cake\ORM\Locator\LocatorAwareTrait;
use Interact\Attribute\Action;
use Interact\Attribute\State;
use Interact\Island;
use Interact\IslandContext;
use TestApp\Model\Table\FormArticlesTable;
/**
* Saves explicit article drafts while demonstrating state updates without HTML replacement.
* Failed entities remain request-local so FormHelper can render and clear validation errors.
*/
class DraftEditorIsland extends Island
{
use LocatorAwareTrait;
/**
* @var array{title: string, body: string} Application-owned editable values.
*/
#[State(writable: true)]
public array $data = ['title' => 'A working draft', 'body' => 'Edit this article and save a draft.'];
#[State]
public ?int $articleId = null;
#[State]
public int $savedCount = 0;
#[State]
public int $signals = 0;
protected ?EntityInterface $article = null;
/**
* Authorize both the initial form and every later action against the current demo role.
*/
public function authorize(IslandContext $context): void
{
/** @var mixed $identity */
$identity = $context->request->getAttribute('identity');
if (!is_array($identity) || ($identity['role'] ?? null) !== 'editor') {
throw new ForbiddenException('Draft editing requires an editor.');
}
}
/**
* Marshal allowed fields and explicitly save; render failures through the failed entity.
*/
#[Action(render: false)]
public function saveDraft(): void
{
$table = $this->articles();
$entity = $this->articleId === null ? $table->newEmptyEntity() : $table->get($this->articleId);
$this->article = $table->patchEntity($entity, $this->data, ['fields' => ['title', 'body'], 'associated' => []]);
if (!$table->save($this->article, ['associated' => []])) {
$this->setErrors($this->article->getErrors() ?: ['_form' => ['Unable to save the draft.']]);
return;
}
$this->articleId = (int)$this->article->get('id');
$this->savedCount++;
$this->dispatch('draft-saved', ['id' => $this->articleId]);
}
/**
* Demonstrate a conditional rendering choice and a response-local browser event.
*/
#[Action]
public function signal(int $count = 1): void
{
$this->signals += $count;
$this->dispatch('draft-signal', ['count' => $this->signals]);
if ($count === 1) {
$this->skipRender();
}
}
/**
* Return the failed entity or a presentation-only entity for the current draft.
*
* @return array{article: EntityInterface}
*/
public function viewData(): array
{
return ['article' => $this->article ?? $this->articles()->newEmptyEntity()->patch($this->data)];
}
/**
* Resolve the normal application Table without transporting it or its entities.
*/
protected function articles(): FormArticlesTable
{
$table = $this->fetchTable('FormArticles');
assert($table instanceof FormArticlesTable, 'Configure the application FormArticles table.');
return $table;
}
}<?php
declare(strict_types=1);
/**
* @var \Interact\View\TemplateView $this
* @var \Cake\Datasource\EntityInterface $article
* @var int $savedCount
* @var array<string, list<string>> $errors
* @var int $signals
*/
$this->Form->setTemplates(['error' => '<div class="error-message field-error" id="{{id}}" role="alert">{{content}}</div>']);
?>
<div class="card-heading">
<h2>Article draft</h2>
<span class="capability">Explicit saves</span>
</div>
<p class="card-description">Save a valid draft repeatedly. The live count advances while the PHP count stays put. An invalid title renders FormHelper errors; correcting it renders once more to clear them.</p>
<?= $this->Form->create($article, [
'url' => false,
'x-interact:model' => 'data',
'x-interact:submit' => '$server.call("saveDraft")',
]) ?>
<?= $this->Form->control('title', ['label' => 'Draft title', 'required' => false]) ?>
<?= $this->Form->control('body', ['label' => 'Draft body', 'type' => 'textarea', 'rows' => 4]) ?>
<div class="form-footer">
<?= $this->Form->button('Save draft', ['class' => 'button-primary']) ?>
<span role="status" x-show="$server.loading('saveDraft')" x-cloak>Saving…</span>
</div>
<?= $this->Form->end() ?>
<?php foreach ($errors['_form'] ?? [] as $message): ?>
<p role="alert" class="field-error"><?= (string)h($message) ?></p>
<?php endforeach; ?>
<p
role="alert"
class="request-error"
x-text="$server.error ? $server.error.message : ''"
></p>
<dl class="navigation-experiments">
<div>
<dt>Live save count</dt>
<dd data-testid="live-saves" x-text="$server.state.savedCount"></dd>
</div>
<div>
<dt>PHP save count at last render</dt>
<dd data-testid="php-saves"><?= $savedCount ?></dd>
</div>
<div>
<dt>Live signal count</dt>
<dd data-testid="live-signals" x-text="$server.state.signals"></dd>
</div>
<div>
<dt>PHP signal count at last render</dt>
<dd data-testid="php-signals"><?= $signals ?></dd>
</div>
</dl>
<div class="button-row">
<button type="button" @click="$server.sync()">Refresh view</button>
<button type="button" @click="$server.call('signal')">Send signal</button>
<button type="button" @click="$server.call('signal', 5)">Send five signals and render</button>
</div><?= $this->Interact->render('DraftEditor', key: 'renderless-draft') ?>Try it
- Edit the title and save repeatedly. The live save count advances; the PHP count and DOM stay in place.
- Submit an empty title. FormHelper renders the failed entity's validation errors while preserving the submitted draft.
- Correct the title and save. Interact renders once to clear those errors. Later successful saves become renderless again.
- Choose Refresh view to render current PHP values without saving again.
- Choose Send signal for conditional
skipRender(), or Send five signals and render for a normal rendering action.
FormHelper takes values and errors from its entity context; no per-control value mapping is necessary. Entities remain request-local. patchEntity() allowlists editable fields and save() explicitly disables association saves. The server-owned article ID returns in signed state so subsequent saves update the same record. Saving and validation still belong to the application.
This demo grants its editor role access to all demo drafts. In an application with per-record access, authorize the current article on every request before loading or saving it. A signed ID does not establish current permission. Keep access-scoped queries in a named Table finder.
The same form protection, loading feedback, keyboard submission, newer-draft preservation, and explicit retry rules apply to renderless actions.