Caveats
Things that behave differently from what you might expect, and the reasons why.
What ends up in the browser
Your components are compiled to JavaScript, so it is fair to ask what else goes with them.
The boundary is a directory. The build walks only the registered component paths: your own, plus one
per package. A component that uses another class inside those paths pulls it in, transitively.
A use pointing at a class outside them is not silently dropped and not silently included. The
build stops:
Class 'App\Services\PaymentGateway' can not be found.
So a component cannot reach into your service layer or your repositories by accident. The accident does not compile.
To keep a class that does live in the component tree out of the bundle, mark it:
use Viewi\Builder\Attributes\Skip;

#[Skip]
class ReportEmail extends BaseComponent
{
}
Or exclude a whole namespace in your config:
$config->noJsNamespace[] = 'Components\\Emails\\';
Two things stay your responsibility:
- A constant, a default value or an API key written into a component class is code in the component tree, and it ships.
- Public component state is serialised into the page so the browser can hydrate it. A public property is visible to the client whether or not you think of it as server-side.
Keep secrets and server-only logic outside the component path.
Arrays in PHP and JavaScript
Arrays are converted differently depending on their contents.
private array $list = [];
By default an empty array becomes a list:
let list = [];
To get an object instead, the counterpart of an associative array, say so with a meta comment:
private array $list = /* @jsobject */ [];
let list = {};
A non-empty array does not need the hint. If it has string keys the transpiler makes an object, if it is a plain list it makes an array.
Reactivity observes assignment, not mutation
Component state is wrapped in proxies, and a proxy sees access on the object it wraps. Mutating an array in place is not an assignment to the property, so nothing observes it:
$this->items[] = $row; // items.push(row), no re-render
$this->items[$i] = $row; // index write, no re-render
The near miss to watch for is that $next = $this->items is a reference in JavaScript, not a copy.
Assigning it back changes nothing, because it is the same array.
Assign a new array:
$next = [];
foreach ($this->items as $existing) {
 $next[] = $existing;
}
$next[] = $row;
$this->items = $next; // re-renders
Derived values belong in a property
A binding records the properties it reads while it evaluates. That is what keeps updates precise, and it means tracking follows the path the expression actually took.
foreach="$items as $item"over a property is reactive.foreach="getItems() as $item"over a method call is not. It renders once and never re-runs.- A binding that calls a method which delegates to other methods records only the reads on that path. Dependencies further down are missed and the binding goes stale.
- A boolean built from a chain of
||stops evaluating at the first truthy operand, and stops recording there too.
Compute derived values into a property where the framework can see them:
public array $gridLines = [];

public function mounted() // props are set before mounted(), not before init()
{
 $this->refreshGrid();
 $this->watch('points', fn() => $this->refreshGrid());
}
Components fetch, they do not mutate
A request a component issues while the server renders does not go over HTTP. The bridge re-invokes the router in process, and the response is handed to the client so the browser does not repeat it.
The consequence: a component whose mounted() performs a POST performs that write while the server
renders a plain GET. It only shows up on a direct open of the page, because navigating to the same
route client side has the browser issue the request itself.
Keep mutations in user actions.
Strict comparisons at the transpile boundary
Every PHP builtin used in a component needs a JavaScript counterpart, and a counterpart's return type can differ from PHP's.
preg_match is the one that catches people. In PHP it returns an int. In the browser it currently
returns a boolean, so this is true on the server and never true in the browser:
if (preg_match($pattern, $value) === 1) { // do not do this
Use a truthy test, which is correct on both sides:
if (preg_match($pattern, $value)) {
The general rule: avoid strict comparisons against the return value of a builtin inside a component.
The tell for this class of bug is a condition that holds when you check the page with curl and
never fires in a browser.
A template file must not end with a newline
A component's .html file has to end at the last >, with no trailing newline. If it ends with
one, the template is treated as a document node rather than an element and hydration fails.
The build succeeds and server rendering is correct, so this only shows up as a page that renders and then does not respond.
perl -0777 -i -pe 's/\n+\z//' Components/**/*.html
Class constants need a use
The transpiler treats a class as a dependency when it sees a constructor type hint, a property type
or a use statement. A bare Other::CONSTANT in a method body is not tracked, so the bundler can
rename the class and leave the reference behind:
ReferenceError: CreateShortUrlModel is not defined
Add the use even when the class is in the same namespace. It is a no-op to PHP and a signal to the
transpiler:
namespace Components\Models;

use Components\Models\CreateShortUrlModel; // redundant to PHP, required here

class CreateShortUrlValidation
{
 public function max(): int
 {
 return CreateShortUrlModel::MAX_ALIAS_LENGTH;
 }
}
Server rendering resolves the class by namespace and works, so this one also only appears in the browser.
Text interpolation next to a sibling element
A bare interpolation sitting beside an element in the same parent renders correctly on the server, but the element's content can duplicate after a reactive re-render:
<td>{{ $row['name'] }}<div>{{ $row['description'] }}</div></td>
Give every child its own element:
<td>
 <div>{{ $row['name'] }}</div>
 <div>{{ $row['description'] }}</div>
</td>
CSS is tree-shaken against literals
The build removes any class not referenced as a literal string in your source, which is how the bundle stays small. A class name assembled at runtime is invisible to it:
// the rule for btn-outline-success is stripped, that string never appears
return 'btn-outline-' . $color;
The element renders unstyled and nothing warns you. Write the variants out:
return match ($color) {
 'success' => 'btn-outline-success',
 'danger' => 'btn-outline-danger',
 default => 'btn-outline-secondary',
};
Lazy load groups decide where a component is registered
Components are split into bundles by namespace prefix:
$config->lazyLoadNamespace['Components\\Views\\Admin\\'] = 'admin';
Two things follow.
A component matching no rule lands in the core bundle that every visitor downloads. Nothing fails, so bundle size is the only symptom. Check the emitted sizes after a build rather than trusting your folder layout.
A component owned by a lazy group is registered only in that group's chunk. If a page in the core bundle mounts it, nothing renders and nothing is logged. Components used from more than one group belong in a shared namespace.