Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
163401 stories
·
33 followers

Microsoft.UI.Reactor: a C#-first way to build WinUI apps

1 Share
  1. Microsoft.UI.Reactor, experimental, part 1 of 3. A C# Render method next to the running counter app it produces.

If you caught our session at Microsoft Build 2026 or you’ve been watching the repo, you’ve seen this one taking shape in public for a while now, and a lot has changed since Build. Here’s the longer tour: Microsoft.UI.Reactor, an experimental C# framework that brings the MVU (Model-View-Update) pattern to WinUI desktop apps. No XAML files, no code-behind, no ViewModels.

Two things before the fun part. Reactor is experimental: no support policy, no API guarantees, published this early on purpose because we’d rather hear we got the design wrong now than once people have shipped on it. And it is not a replacement for XAML or MVVM, which we’ll come back to.

Now the fun part.

What is Reactor?

Reactor is not a new UI platform. There is no new renderer and no fork of anything. Reactor renders WinUI controls and primitives: write Button, TextBox, NavigationView, or TreeView and you get the real WinUI control, laid out by the same panels, themed by the same resources, inspected by the same tools. A few pieces are ours, like the flex layout panel, but those are built on the same primitives your XAML app uses. What you ship is as close to the platform as it gets.

What changes is how you describe them, and the pattern you use to do it.

XAML apps are usually built with MVVM (Model-View-ViewModel): state lives on a view model, it announces changes, and bindings pull the new values into controls. Reactor uses MVU (Model-View-Update): state lives in one place, an update function returns the next state, and the view is a plain function of that state. Elm popularized it; React, SwiftUI, and Jetpack Compose are all variations on it.

Neither is a newer version of the other. They’re two established patterns with different trade-offs, and WinUI can now run both.

So instead of writing markup that gets compiled into a control tree, you write a C# function that returns an immutable description of what the UI should look like right now. Reactor diffs that description against the previous one and patches only what actually changed on the real controls underneath.

Why are we doing this? To find out how far MVU can go on WinUI, and whether it makes WinUI feel familiar to developers who already think that way. If you’ve built UI with React, React Native, SwiftUI, or Jetpack Compose, the usual WinUI path starts with learning a markup language and a binding engine, and we want to find out whether Reactor can remove that step. UI written as plain C# also turned out to be easy for coding agents to read, write, and check, so we now design for agents as well as people. And Reactor gives WinUI a place to experiment: new controls and patterns get tried here first, and the ones that prove themselves are candidates to land in WinUI itself, where they’ll work for XAML apps too. None of this is about replacing anyone: not XAML, and not you.

Here is an entire Reactor application. Not a piece of one, but the whole thing, including its package reference and build settings:

#:package Microsoft.UI.Reactor@0.1.0-preview.16
#:property OutputType=WinExe
#:property TargetFramework=net10.0-windows10.0.22621.0
#:property UseWinUI=true

using Microsoft.UI.Reactor;
using Microsoft.UI.Reactor.Core;
using static Microsoft.UI.Reactor.Factories;

ReactorApp.Run<MyApp>("Hello Reactor", width: 340, height: 200);

class MyApp : Component
{
    public override Element Render()
    {
        var (count, setCount) = UseState(0);

        return VStack(
            TitleBar("Hello Reactor"),
            VStack(12,
                Heading($"Count: {count}"),
                HStack(8,
                    Button("-", () => setCount(count - 1)),
                    Button("+", () => setCount(count + 1))
                        .AccentButton()
                )
            ).Padding(24)
        ).Backdrop(BackdropKind.Mica);
    }
}

Save that as counter.cs and run it:

winapp run counter.cs

A real WinUI window opens, with a real TitleBar, a TextBlock and two Buttons, and the counter works.

The counter running in a real WinUI window titled Hello Reactor, with Count: 3 above a minus button and a blue plus button.

Those #: lines at the top are .NET 10’s file-based apps: package references and build properties live in the same file as the code, which makes for a quick way to try things. A more complex app uses a normal project, and there’s a template for that; see Try it below. The Reactor code doesn’t change either way.

If that snippet made you feel something (either “oh, finally” or “absolutely not”), hold onto that reaction. Both answers are useful to us, and there’s a comment box waiting at the end of the post. First impressions are data we can’t get any other way, and there’s plenty more below to react to.

By the way, what’s winapp?

Quick aside, since it’s the first command in this post. winapp is Microsoft’s CLI for Windows app development: scaffolding, package identity, MSIX packaging and signing. It works with any framework targeting Windows, not just Reactor. v0.7.0 just shipped, and it’s what lets you run a .cs file directly like we did above. We use it for every sample in this series because it handles the boilerplate. Prefer dotnet? Everything here works there too, with a flag or two more: dotnet run counter.cs -a x64 -p:WindowsAppSDKSelfContained=true. That runs it as a plain unpackaged app, though. Package identity and MSIX packaging are what winapp adds.

The same app, side by side

Let’s be fair about this comparison, because unfair comparisons are how frameworks get a reputation they don’t deserve. We’ll write the XAML version the way a good XAML developer would actually write it in 2026: x:Bind, and the MVVM Toolkit‘s source generators doing the heavy lifting.

The XAML version. Five files and a project, at minimum: the window’s markup, its code-behind, a view model, App.xaml and App.xaml.cs to bootstrap it, and a .csproj to build it. You don’t need to read the code to follow along, but here are the three files that hold the counter:

Show MainWindow.xaml, MainWindow.xaml.cs, and CounterViewModel.cs
<!-- MainWindow.xaml -->
<Window x:Class="CounterApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Hello XAML">
    <Window.SystemBackdrop>
        <MicaBackdrop />
    </Window.SystemBackdrop>
    <StackPanel>
        <TitleBar x:Name="AppTitleBar" Title="Hello XAML" />
        <StackPanel Spacing="12" Padding="24">
            <TextBlock Text="{x:Bind ViewModel.CountText, Mode=OneWay}"
                       Style="{StaticResource TitleTextBlockStyle}" />
            <StackPanel Orientation="Horizontal" Spacing="8">
                <Button Content="-" Command="{x:Bind ViewModel.DecrementCommand}" />
                <Button Content="+" Command="{x:Bind ViewModel.IncrementCommand}"
                        Style="{StaticResource AccentButtonStyle}" />
            </StackPanel>
        </StackPanel>
    </StackPanel>
</Window>
// MainWindow.xaml.cs
public sealed partial class MainWindow : Window
{
    public CounterViewModel ViewModel { get; } = new();

    public MainWindow()
    {
        InitializeComponent();
        ExtendsContentIntoTitleBar = true;
        SetTitleBar(AppTitleBar);
    }
}
// CounterViewModel.cs
public partial class CounterViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(CountText))]
    public partial int Count { get; set; }

    public string CountText => $"Count: {Count}";

    [RelayCommand] private void Increment() => Count++;
    [RelayCommand] private void Decrement() => Count--;
}

The Reactor version. The MyApp class from earlier. A more complex app has a .csproj too, but the counter itself is one class, in one language.

Now: that XAML is good XAML. The source generators erase most of the boilerplate that made MVVM famous. But look at what’s still true:

  • The value flows across three files and two languages before it reaches a pixel.
  • {x:Bind ViewModel.CountText} is a string that the XAML compiler has to be taught to understand. Rename CountText from C# and you’d better hope your tooling follows.
  • The state lives in a class whose entire reason to exist is being observed.
  • “What does this screen look like when Count is 5?” is a question you answer by reading three files and mentally running a binding engine.

In Reactor, that last question has a very boring answer: read Render(). It’s a function. Give it a count, it gives you a UI. That’s the whole idea, and most of what Reactor is good at follows from it.

One question before we move on: does this style look like it would suit something you’re working on, or does it look like more trouble than the XAML version? Both are useful answers. “I’d stay with XAML for this screen” is a perfectly good one: we’re trying to learn which kinds of screens each model fits, not which one wins. Comments are open.

So what do you actually get?

Beyond “fewer files,” which is nice but not a strategy: everything is compile-time. No binding failures in the output window at runtime, and renaming a property updates every usage, because every usage is C#. State lives next to the UI that uses it, so there’s no ViewModel class and no PropertyChanged plumbing. And a good deal comes in the box: flexbox layout via a faithful C# port of Meta’s Yoga engine, commanding, navigation, theming tokens, localization, a DataGrid, and a docking system.

The one worth calling out is tooling. Because your UI is ordinary C#, the entire Roslyn toolchain points at it, and we’ve shipped 60+ Reactor-specific analyzers covering hooks, accessibility, theming, commanding, and performance. They catch the things that are mechanically detectable but invisible in markup: a list item missing a key, a hook called inside an if, a hard-coded brush that won’t survive dark mode, an icon-only button a screen reader would announce as “button”. You find out three seconds after you type it, not three weeks before ship.

Where XAML fits

WinUI is the foundation. XAML is a first-class way to use it, it isn’t being deprecated, and it’s still getting active investment. The XAML + MVVM model fits a large part of the WinUI user base, and the tooling, designer, templating, guidance, and third-party control ecosystem built around it aren’t going anywhere. WinUI development is moving into the open on GitHub, and platform tools like the winapp CLI are built for any framework targeting Windows, not for Reactor. Reactor sits on top of all of it.

So the two models coexist on purpose. If you’re shipping XAML/MVVM, keep shipping it. If you want MVU with no XAML, Reactor gets you there on the same runtime and the same controls. And if you want both, they interoperate in both directions, in the same process and the same visual tree, so nobody has to pick a side.

Missing a control? Any other WinUI control, third-party or your own, can be wrapped as a Reactor element and used like the built-in ones.

It’s open source. All of it.

Everything is on GitHub under the MIT license: microsoft/microsoft-ui-reactor. Framework code, design specs, sample apps, the full test suite, the CLI, the analyzers, and the VS Code extension. Clone it, build it, break it, tell us about it.

This isn’t a one-off. It’s the same direction WinUI itself is heading: it has been moving its day-to-day development into the open on microsoft/microsoft-ui-xaml, so the community can see the roadmap, follow the work, and contribute to the platform directly. Reactor was born in that world rather than being retrofitted into it: it has been public from its first commit, and there is no internal branch where the “real” work happens.

Same team. Same repo culture. Same belief that the Windows developer platform gets better faster when you can see it being built.

Reactor isn’t just running in our own samples. OpenClaw’s Windows companion app uses it in production for its chat, on by default. That app is open source too, so you can read exactly how a shipping app puts Reactor to work.

Try it

You’ll want the .NET 10 SDK and the winapp CLI (v0.7 or later, which is where running a .cs file directly arrived):

winget install Microsoft.WinAppCli

The fastest path is the one you already read. Save the counter from the top of this post as counter.cs and run it:

winapp run counter.cs

That builds the file, generates a package manifest from the #:property directives at the top of it, and launches the app with real package identity, not a loose .exe.

Prefer to stay in dotnet? dotnet run counter.cs -a x64 -p:WindowsAppSDKSelfContained=true builds and runs the same file, unpackaged. Add a #:package Microsoft.Windows.SDK.BuildTools.WinApp@* line and dotnet run gives you the packaged launch too, which is exactly what winapp is doing under the hood.

When you want a real project rather than a scratch file:

winapp new -t reactor -n MyApp
cd MyApp
winapp run

That scaffolds a packaged app (Package.appxmanifest, assets, publish profiles, the lot) and installs the WinUI template pack for you if you don’t have it. Debug builds also pull in the Reactor devtools. Four templates are available, all experimental: reactor (blank), reactor-mvu (state, actions, and a reducer wired up), reactor-navview, and reactor-tabview. Run winapp new --list to see the rest, including the XAML ones: the same CLI scaffolds both.

Prefer Rust?

The idea isn’t tied to C#. Over in windows-rs, the Rust for Windows project, we’re running the same experiment in Rust: windows-reactor is a declarative WinUI library where components own their state, controls send typed messages back, and only the WinUI controls that changed get updated.

It’s early days there too, and it’s already on crates.io. Different language, same native controls underneath.

What we’d like to know

“Let us know what you think” is the emptiest sentence in developer relations, so here are the actual questions keeping us up at night. Answer any one of them in the comments and you’ve moved this project forward:

  1. Does the DSL read well at scale? Twelve lines look great. Does a 400-line screen? We’re actively working with the C# language team, and the syntax can still change, but only while the experimental label is on.
  2. Where does it break down? Which control, which layout, which scenario made you reach for XAML instead? That’s the single most valuable thing you can tell us.
  3. Is interop good enough? If you can’t adopt this incrementally inside an existing app, it doesn’t matter how nice the greenfield story is.
  4. Is this a bad idea? If you think WinUI doesn’t need this, we want that comment too. A well-argued “no” from a working Windows developer is worth more to us right now than a dozen stars.

The experimental label stays on for the next few months while we iterate in public. Every line of code is fair game: the DSL syntax may shift, controls may come and go, the layering may get reorganized. This is the window where feedback changes the design instead of getting filed under “breaking change, too late.”

Wherever you’re most comfortable, in rough order of how little effort it costs you:

Next month

No counters. We’ll tour the twenty-odd real apps living in the repo: a registry editor, a chat client, an IDE with tear-off docking panes, then build a full minesweeper board from an empty folder. And once you’ve watched it hold up under 256 interactive controls, we’ll answer the question we already know you’re going to ask: where does the state actually live when there’s no ViewModel to put it in.

Until then: clone it, build something small, and tell us where it hurts. That’s the whole ask.

Happy coding!

The post Microsoft.UI.Reactor: a C#-first way to build WinUI apps appeared first on #ifdef Windows.

Read the whole story
alvinashcraft
just a second ago
reply
Pennsylvania, USA
Share this story
Delete

What Is Model Distillation? A Guide for Developers

1 Share

Over time, knowledge has been passed down in various forms. Before writing was invented in ancient Mesopotamia (modern-day Iraq), laws, history, myths, songs, and other information were all transferred from generation to generation orally. This was done either via instructions or storytelling (especially folktales and events that had happened over time).

But another common method of knowledge transfer is from teacher to student, whether formally or informally. We’ve seen this throughout time as well: the Bible recorded that Jesus had twelve disciples whom he taught daily from the start of his mission to his death. Socrates taught Plato. Plato went on to establish an academy in Athens, where he taught Aristotle for twenty years. Aristotle revolutionized science, logic, and politics. And the list goes on.

A 21st-century example of this is deep learning AI lineage. During the 20th-century AI winter, when we thought neural networks were a dead end, this teacher-student lineage kept the dream alive.

Geoffrey Hinton, regarded as the father of AI, ran a lab where he taught Yann LeCun, Ilya Sutskever, and others. Yann LeCun went on to invent the convolutional neural network (CNN), while Ilya Sutskever co-founded OpenAI before leaving for Safe Superintelligence Inc.

While the concept of knowledge transfer between humans is quite familiar, how about knowledge transfer from machine to machine? This is known as "model distillation."

In this article, I'm going to break down what model distillation is. We'll explore the techniques it uses, and I'll walk you through how to implement it from scratch using PyTorch.

By the end, you'll understand how to train smaller, more efficient models and how distillation compares to other compression techniques like quantization and pruning.

What We'll Cover

Prerequisites

To follow along, you should have the following:

  • A GitHub Repository: The complete implementation of the project is available here, and you should clone it to follow along.

  • Basic familiarity with Python and object-oriented programming concepts (for example, working with classes).

  • An overview of deep learning fundamentals. You should understand concepts like epochs, forward propagation, loss function, and so on.

  • PyTorch Basics: While I'll explain each code section for you, basic familiarity with PyTorch tensors and training loops will be helpful.

  • A development environment: You should have Python installed on your machine. I'll be using Visual Studio Code as my editor, but you can use any editor or IDE of your choice.

  • Hardware: For this project, we'll be using a relatively small dataset so a standard CPU will work well.

What is Model Distillation?

Model distillation (or knowledge distillation) is an approach that aims to pass on the learned behavior of a large, highly accurate model to a smaller, computationally efficient model that's better suited for practical deployment.

Distillation is a compression technique where a smaller model (known as the student) learns to mimic the output of a larger, more complex model (the teacher). The goal is for the student model to achieve a level of performance close to the teacher, but with significantly fewer parameters, making it more efficient for deployment. (Source)

Why is Model Distillation Necessary?

At the time of this writing, both closed-source and open-source models are trained on trillions of parameters. These are the internal configuration variables that models learn automatically from training data. This immense number of parameters makes it extremely difficult and expensive to run without proper funding. Models consume large amounts of energy and require expensive hardware.

With distillation, we can transfer the learnings of these frontier models to smaller, more efficient models, making them easier and cheaper to use.

Model Distillation Techniques

The distillation technique used depends on the knowledge that's being transferred. Some methods transfer only the final prediction. Others transfer internal representation, attention maps, reasoning steps, or even the entire behavior of the teacher model.

These are some of the techniques:

Response-Based Distillation

Originally introduced by Geoffrey Hinton in his paper Distilling the knowledge in a neural network, this is the classic teacher-to-student distillation. Before diving deep into how it works, there are a few concepts you need to understand about model training: hard labels and soft labels.

Hard labels deal strictly with fixed categorical targets. When a picture of a dog is fed into a model and it's asked to predict if it's a dog, cat, or horse, it predicts it's a dog, given the result as Dog(1), horse(0), and cat(0). There's no probability as to what percentage or features of the picture look like a cat or a horse.

But we also have soft labels, which deal with probability. When the same picture is fed to a model using soft labels, it can predict that the picture has a 0.85 probability of being a dog, a 0.10 probability of being a cat, and a 0.5 probability of being a horse.

In response-based distillation, instead of training the student model with hard labels, it also learns from the probability of the teacher model's output (soft labels). The student can learn that the picture is a dog, but it also learns that the picture also slightly resembles a cat and barely resembles a horse.

This additional information is called dark knowledge. It enables the smaller model to copy the behavior of a larger, more accurate teacher while maintaining high performance.

Some of the advantages of response-based distillation are that it's easy to implement, it's very stable, and it works well with image classification. But there are some drawbacks, the major one being that it ignores what happens inside the network.

That is, response base distillation only looks at the output layer (the probabilities), so the student has a "black-box" relationship with the teacher model. It learns what the teacher decides, but not how it comes to that conclusion. Using the previous example, it learns the probability of what feature is a dog, cat and horse but not the underlying principle or workings of how that conclusion was made.

Feature-Based Distillation

This technique transfers the internal feature representation of the larger model rather than transferring only the final output.

An easy way to think about this is to imagine that you're a student of a great math tutor. The tutor gives you a math problem and shows you their final result. You might eventually guess the right formula, but you're mostly just learning to memorize output.

But if the tutor requires you to look at their notebook and see their work, you're learning the tutor's exact thought process.

In feature-based distillation, during training, the student model is required to produce hidden layer activations that match those of the tutor using Mean Squared Error (MSE). With mean squared error, the student model isn't just rewarded for correctly guessing the final output. It's also penalised if its internal function system doesn't match that of the teacher model.

This method is heavily used in convolutional neural networks, vision transformers, object detection, and so on.

The drawbacks of this method are that since the student has to extract and store internal feature map of the teacher model at every training iteration, memory and power consumption can quickly escalate. This makes it very expensive to run.

Attention Distillation

In this approach, instead of the student model copying all the neurons, it observes where the larger model pays attention and copies those neurons.

In transformer models, which are models that try to make sense of a sentence instead of paying attention to individual words, attention maps indicate which input tokens or regions the model considers most important when making predictions. The student tries to reproduce those maps or the distribution of the teacher model. This shows the student where to "look" when trying to solve a problem.

The drawback of attention distillation is substantial overhead in both training time and hardware resource. This is because metrics like Mean Squared Error (MSE) and Kullback-Leibler (KL) divergence need to be calculated. These metrics act as a loss function that tells a model exactly how much it's prediction or internal representation differs from the teacher model. Making this calculation across massive attention tensors contribute significantly to resource consumption.

Self-Distillation

A model is also capable of training itself. This involves knowledge transfer from an earlier version to a later version. It can also involve the transfer of knowledge between different layers in the network.

An advantage of this is that the model can become better without necessarily growing in size, reducing the cost of using a larger model to train the student model.

How to Implement Knowledge Distillation in PyTorch.

To showcase how model distillation works in real time, we'll be training a teacher and then a student model using the MNIST dataset and classifier with PyTorch.

The dataset contains about 70,000 greyscale images of handwritten digits. We'll train the teacher, then freeze it while we train a student using hard labels and soft targets from the teacher.

Note that the MNIST dataset is a relatively small dataset, so our distillation gains might not be much. But this project showcases the distillation workflow, and it will give you a good background to run larger datasets.

To run this project, you'll need the following Python packages:

Package Purpose
torch The core deep learning framework we'll use to build and train neural networks.
torchvision Provides datasets like MNIST and image utilities.
numpy Numerical computations and array operations.
matplotlib Plotting graphs and visualizing the visual images/results.
tqdm Displays progress bars during training.

For this project, I'll be working with the Visual Studio Code editor, but you can use any other code editor of your choice.

This is our project structure. I'll be explaining the folders and files below alongside the code for our project.

knowledge-distillation/
│
├── data/
│
├── models.py
│
├── engine.py
│
├── losses.py
│
├── train_teacher.py
│
├── train_student.py
│
├── train_distilled_student.py
│
├── evaluate.py
│
├── visualize.py
│
├── utils.py
│
├── plots.py
│
├── requirements.txt
│
└── README.md

First in our folder structure is our data folder. This contains our MNIST dataset.

Our models.py file defines the architecture of every neural network used in the project. It contains the teacher network class and student network class. Each class defines layers, forward propagation, and how data flow through the network.

import torch
import torch.nn as nn
import torch.nn.functional as F

class TeacherNet(nn.Module):

    def __init__(self):
        super().__init__()

        self.flatten = nn.Flatten()

        self.fc1 = nn.Linear(28 * 28, 1024)
        self.fc2 = nn.Linear(1024, 512)
        self.fc3 = nn.Linear(512, 256)
        self.fc4 = nn.Linear(256, 128)
        self.fc5 = nn.Linear(128, 10)

    def forward(self, x):

        x = self.flatten(x)

        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = F.relu(self.fc3(x))
        x = F.relu(self.fc4(x))

        logits = self.fc5(x)

        return logits


class StudentNet(nn.Module):
    """
    Smaller neural network (Student)
    """

    def __init__(self):
        super().__init__()

        self.flatten = nn.Flatten()

        self.fc1 = nn.Linear(28 * 28, 128)
        self.fc2 = nn.Linear(128, 10)

    def forward(self, x):

        x = self.flatten(x)

        x = F.relu(self.fc1(x))

        logits = self.fc2(x)

        return logits

The utils.py file prepares the dataset and answers the question, "Where does the dataset come from?" Here, we download the MNIST dataset, normalize the images, and create our training and testing loader.

from torchvision import datasets, transforms
from torch.utils.data import DataLoader


def get_dataloaders(batch_size=64):
    """
    Downloads MNIST (if needed) and returns
    training and testing DataLoaders.
    """

    transform = transforms.ToTensor()

    train_dataset = datasets.MNIST(
        root="data",
        train=True,
        download=True,
        transform=transform
    )

    test_dataset = datasets.MNIST(
        root="data",
        train=False,
        download=True,
        transform=transform
    )

    train_loader = DataLoader(
        train_dataset,
        batch_size=batch_size,
        shuffle=True
    )

    test_loader = DataLoader(
        test_dataset,
        batch_size=batch_size,
        shuffle=False
    )

    return train_loader, test_loader

The engine.py file contains the reusable learning algorithm and helps with how training happens. It receives the model.py code and trains it.

import torch
from tqdm import tqdm


def train_one_epoch(
    model,
    dataloader,
    criterion,
    optimizer,
    device,
):
    model.train()

    running_loss = 0

    progress = tqdm(dataloader)

    for images, labels in progress:

        images = images.to(device)
        labels = labels.to(device)

        optimizer.zero_grad()

        outputs = model(images)

        loss = criterion(outputs, labels)

        loss.backward()

        optimizer.step()

        running_loss += loss.item()

        progress.set_description(
            f"Loss {loss.item():.4f}"
        )

    return running_loss / len(dataloader)


def evaluate(model, dataloader, device):

    model.eval()

    correct = 0
    total = 0

    with torch.no_grad():

        for images, labels in dataloader:

            images = images.to(device)
            labels = labels.to(device)

            outputs = model(images)

            _, predicted = torch.max(outputs, dim=1)

            total += labels.size(0)

            correct += (predicted == labels).sum().item()

    accuracy = 100 * correct / total

    return accuracy


def predict(model, images, device):

    model.eval()

    with torch.no_grad():

        images = images.to(device)

        outputs = model(images)

        _, predictions = torch.max(outputs, dim=1)

    return predictions

def train_distillation_epoch(
    teacher,
    student,
    dataloader,
    optimizer,
    device,
    loss_fn,
    temperature=4.0,
    alpha=0.5,
):
    """
    Train the student using a frozen teacher.
    """

    teacher.eval()
    student.train()

    running_loss = 0

    progress = tqdm(dataloader)

    for images, labels in progress:

        images = images.to(device)
        labels = labels.to(device)

        optimizer.zero_grad()

        # Teacher predictions (NO gradients)
        with torch.no_grad():
            teacher_logits = teacher(images)

        # Student predictions
        student_logits = student(images)

        # Combined loss
        loss = loss_fn(
            student_logits=student_logits,
            teacher_logits=teacher_logits,
            labels=labels,
            temperature=temperature,
            alpha=alpha,
        )

        loss.backward()

        optimizer.step()

        running_loss += loss.item()

        progress.set_description(
            f"Distill Loss {loss.item():.4f}"
        )

    return running_loss / len(dataloader)

The losses.py file defines how the model measures its mistake. Since a neural network learns by minimizing loss, for baseline visualize used CrossEntropyLoss, and for knowledge distillation, we introduced distillation_loss().

Inside this file, we combined hard labels and soft targets using cross entropy and k1 divergence. This is where Geoffrey Hinton's algorithm lives.

import torch
import torch.nn as nn
import torch.nn.functional as F

cross_entropy = nn.CrossEntropyLoss()


def distillation_loss(
    student_logits,
    teacher_logits,
    labels,
    temperature=4.0,
    alpha=0.5,
):
    """
    Geoffrey Hinton's Knowledge Distillation Loss

    Parameters
    ----------
    student_logits : output from student model
    teacher_logits : output from teacher model
    labels         : ground-truth labels
    temperature    : controls softness of probability distribution
    alpha          : weight between hard and soft losses
    """

    # Hard Label Loss
    hard_loss = cross_entropy(student_logits, labels)

    # Teacher Soft Targets
    teacher_probs = F.softmax(
        teacher_logits / temperature,
        dim=1
    )

    # Student Soft Predictions
    student_log_probs = F.log_softmax(
        student_logits / temperature,
        dim=1
    )

    # KL Divergence
    soft_loss = F.kl_div(
        student_log_probs,
        teacher_probs,
        reduction="batchmean"
    )

    # Final Distillation Loss
    loss = (
        alpha * hard_loss
        +
        (1 - alpha)
        * (temperature ** 2)
        * soft_loss
    )

    return loss

The train_teacher.py file is to train the teacher network. After running this for the first time, we don't train the teacher again.

import torch
import torch.optim as optim

from models import TeacherNet
from utils import get_dataloaders
from engine import train_one_epoch, evaluate
from losses import cross_entropy

device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu"
)

train_loader, test_loader = get_dataloaders()

teacher = TeacherNet().to(device)

optimizer = optim.Adam(
    teacher.parameters(),
    lr=0.001,
)

epochs = 10

for epoch in range(epochs):

    loss = train_one_epoch(
        teacher,
        train_loader,
        cross_entropy,
        optimizer,
        device,
    )

    accuracy = evaluate(
        teacher,
        test_loader,
        device,
    )

    print(
        f"Epoch {epoch+1}/{epochs}"
        f" | Loss: {loss:.4f}"
        f" | Accuracy: {accuracy:.2f}%"
    )

torch.save(
    teacher.state_dict(),
    "teacher.pth",
)

print("\nTeacher saved successfully.")

The train_student.py file is where we train the student. It's identical to that of the train_teacher except it's a smaller network and no teacher guidance is implemented here. This gives the baseline accuracy of how the student will perform without the teacher.

import torch
import torch.optim as optim

from models import StudentNet
from utils import get_dataloaders
from engine import train_one_epoch, evaluate
from losses import cross_entropy

device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu"
)

train_loader, test_loader = get_dataloaders()

student = StudentNet().to(device)

optimizer = optim.Adam(
    student.parameters(),
    lr=0.001,
)

epochs = 10

for epoch in range(epochs):

    loss = train_one_epoch(
        student,
        train_loader,
        cross_entropy,
        optimizer,
        device,
    )

    accuracy = evaluate(
        student,
        test_loader,
        device,
    )

    print(
        f"Epoch {epoch+1}/{epochs}"
        f" | Loss: {loss:.4f}"
        f" | Accuracy: {accuracy:.2f}%"
    )

torch.save(
    student.state_dict(),
    "student_baseline.pth",
)

print("\nBaseline student saved.")

The train_distilled_student.py is the heart of this code. In this file, we teach the student using both labels and the teacher's knowledge. Here, we implement response-based knowledge distillation.

import torch
import torch.optim as optim

from models import TeacherNet, StudentNet
from utils import get_dataloaders
from engine import (
    train_distillation_epoch,
    evaluate,
)
from losses import distillation_loss

device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu"
)

train_loader, test_loader = get_dataloaders()

# Load Teacher
teacher = TeacherNet().to(device)
teacher.load_state_dict(
    torch.load(
        "teacher.pth",
        map_location=device,
    )
)

teacher.eval()

# Student
student = StudentNet().to(device)

optimizer = optim.Adam(
    student.parameters(),
    lr=0.001,
)

epochs = 10

for epoch in range(epochs):

    loss = train_distillation_epoch(
        teacher,
        student,
        train_loader,
        optimizer,
        device,
        distillation_loss,
        temperature=3,
        alpha=0.7,
    )

    accuracy = evaluate(
        student,
        test_loader,
        device,
    )

    print(
        f"Epoch {epoch+1}/{epochs}"
        f" | Loss: {loss:.4f}"
        f" | Accuracy: {accuracy:.2f}%"
    )

torch.save(
    student.state_dict(),
    "student_distilled.pth",
)

print("\nDistilled student saved.")

The evaluate.py measures how well a trained model performs. Here, we simply load the model, load test data, predict, and compute the accuracy.

import torch

from models import TeacherNet
from utils import get_dataloaders
from engine import evaluate

device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu"
)

_, test_loader = get_dataloaders()

teacher = TeacherNet().to(device)

teacher.load_state_dict(
    torch.load(
        "teacher.pth",
        map_location=device,
    )
)

accuracy = evaluate(
    teacher,
    test_loader,
    device,
)

print(
    f"Teacher Accuracy: {accuracy:.2f}%"
)

The visualize.py file is to present our findings visually. We use the Python package matplotlib to visualise the results of our teacher, baseline student, and distilled student.

import matplotlib.pyplot as plt

models = [
    "Teacher",
    "Baseline\nStudent",
    "Distilled\nStudent"
]

accuracy = [98.20, 97.86, 97.91]

plt.figure(figsize=(7,5))
plt.bar(models, accuracy)

plt.title("Knowledge Distillation Performance Comparison")
plt.ylabel("Accuracy (%)")

for i, v in enumerate(accuracy):
    plt.text(i, v + 0.02, f"{v:.2f}%", ha="center")

plt.ylim(97.5, 98.5)

plt.tight_layout()
plt.savefig("accuracy_comparison.png", dpi=300)
plt.show()

You can see the entire workflow of our program below so you understand how everything comes together:

                       utils.py
                          │
                 Load MNIST Dataset
                          │
                          ▼
                     models.py
             TeacherNet / StudentNet
                          │
                          ▼
                     losses.py
  Cross Entropy + Distillation Loss Function
                          │
                          ▼
                     engine.py
    Generic Training and Evaluation Functions
             ┌────────────┼────────────┐
             │            │            │
             ▼            ▼            ▼
  train_teacher.py  train_student.py  distilled_student.py
             │            │            │
             ▼            ▼            ▼
   teacher.pth  student_baseline.pth  distilled.pth
                          │
                          ▼
                     evaluate.py
              Compute Final Accuracy
                          │
                          ▼
                    visualize.py
     Generate Charts and Experimental Figures

How to Evaluate the Results

Now, let's check and evaluate our results. These are screenshots from running ten epochs for each of the networks: the teacher, student, and distilled student network.

Teacher Network: After 10 complete cycles, we attained an accuracy of 98.20%.

Teacher model training phase of 10 epoch, it achieved accuracy of 98.20%

Student Network: After 10 complete cycles, we attained an accuracy of 97.74%. This is a pretty good performance for a smaller network without the teacher model.

Baseline student model training phase of 10 epoch, it achieved accuracy of 97.74%

Distilled Student Network: After 10 complete cycle, we attained an accuracy of 98.20%. We had an improvement from 97.74% achieved without the teacher's model knowledge.

Distilled Student model training phase of 10 epoch, it achieved accuracy of 97.82%

Check out the chart below that shows how each model performed:

A graph of Teacher model achieving 98.20%, Baseline Student achieved 97.86% and Distilled Student achieved 97.91%

Other Approaches for Reducing Model Size

Now that we've seen what model distillation is with real-world code examples. Let's look at other approaches that developers are taking to reduce the size of large language models to save on latency, memory footprints, and compute cost and how they differ from distillation.

We have three other major approaches for this:

Quantization

Quantization achieves efficiency by strictly reducing the bit-width of the underlying data types used to store numerical weight and activations of the neural network. It targets the memory bandwidth bottleneck rather than the model architecture.

In simple terms, quantization can change a 32-bit number to 8-bit numbers, which saves about 75% of the file size.

A real-world example of how quantization differs from distillation is trying to save resources like ink and paper when printing a textbook. Distillation summarizes the textbook, while quantization prints the exact thing but uses a lower-resolution font to save ink and paper.

Pruning

Pruning operates on the principle that large language models often have too many redundant and low-impact parameters on the network. By removing weak connections, models can become smaller and faster.

For the textbook analogy, the pruning process is like going through the textbook and ripping off pages or chapters that you feel aren't needed.

Low-Rank Factorization

This is a mathematical process that uses linear algebra to simplify the matrix multiplication that dominates the deep learning architecture.

With the textbook example, this process involves rewriting complex sentences into simpler, shorter formulas that mean the same thing.

Choosing which method to use depends on the bottleneck you're trying to solve. Quantization generally works well for memory challenges, while pruning and distillation are designed to reduce computation time.

Conclusion

Knowledge transfer is as fundamental to artificial intelligence as it is to human history. Just as Aristotle built upon the teachings of Plato, today's efficient, deployable neural networks rely on the foundational "dark knowledge" extracted from massive, compute-heavy teacher models.

While training trillion-parameter frontier models requires immense hardware and financial resources, knowledge distillation ensures that the practical benefits of these advancements aren't locked behind massive computational bottlenecks.

As we demonstrated with the PyTorch MNIST experiment, even a simple response-based distillation can help a smaller network punch above its weight class by learning from the soft probabilities of a larger teacher.

When combined with other optimization techniques like quantization and pruning, distillation provides a clear pathway for deploying highly capable AI on edge devices, mobile phones, and consumer-grade hardware.

Thank you for reading! Please check out my YouTube channel, where I post videos on AI-related content.

References:

  1. What does it mean to distill a machine learning model or LLM?

  2. AI Insights: Model Distillation from the Department for Science, Innovation and Technology

  3. Model distillation for LLMs: A practical guide to smaller, faster AI

  4. Distilling knowledge in a neural network by Geoffrey Hinton

  5. Everything you need to know about knowledge distillation



Read the whole story
alvinashcraft
11 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

How to Diagnose vite: not found After npm ci in a Container Build

1 Share

I recently worked on a project where a Node.js application used Vite to compile its frontend. It worked in a browser-hosted development environment, but its image build failed after the move to Azure Container Registry. The build reported a missing Vite executable after an earlier dependency install had failed.

The container build ended with a simple error:

sh: 1: vite: not found

Vite already appeared in devDependencies. The build stage had run npm ci before calling the build script. The Dockerfile looked ordinary, so the first explanations all pointed at dependency selection or NODE_ENV.

The missing binary came from an earlier failure. npm ci had tried to download hundreds of tarballs from a package host that only existed inside the browser-based development environment. It printed Exit handler never called!, left node_modules empty, and didn't give Docker a clear failure status. The following layer reached npm run build and reported the first missing executable it needed.

The lockfile pointed npm at an unreachable host. This tutorial shows how to check whether the install completed, find the affected tarball URLs, and check the dependency layer before compiling the application. The repair changes the public-package URL prefix and adds build checks. Package versions, integrity values, application code, and Vite configuration stay the same.

Table of Contents

Before You Begin

You need a Node.js project with package.json, a version 2 or 3 package-lock.json, its container build log, and Docker. The example uses a supported Node 24 image and a Vite project that writes its build output to dist. Check your own dependency compatibility and output directory.

Check whether the affected packages come from a public or private registry before changing any URLs. The .invalid hostname below stands in for the original internal host. Adapt that prefix only after reviewing the packages it serves.

How the Build Reached the Failure

The application came from a browser-hosted development environment and later moved into an Azure Container Registry build. The development platform acted as a proxy for public npm packages. When it regenerated package-lock.json, it stored its own internal tarball URLs in the resolved fields.

The failure crossed two environments:

  1. Inside the development environment, npm could reach the package proxy. The lockfile kept those proxy URLs.

  2. The Azure Container Registry image builder received the same lockfile, but it couldn't resolve the proxy hostname.

  3. The dependency install printed Exit handler never called! and left node_modules empty. Its failure status didn't stop the next Docker layer in this incident.

  4. The next layer ran npm run build, which called Vite. With no installed Vite executable, the build reported vite: not found.

The checks below follow that chain backward from the missing executable to the package locations, then repair the URLs and guard each independent install.

A lockfile points npm at an unreachable package host. npm leaves node_modules empty, and the next Docker layer reports that Vite is missing.

Figure 1. The container build reported the last failure in the chain.

Step 1: Check Whether npm ci Installed Anything

The project used npm ci, which was the correct command for a repeatable container build. npm's documentation says that the command removes an existing node_modules directory before it starts the install. It also treats the lockfile as frozen instead of updating it.

Those properties made this failure unusually clear. The build couldn't fall back to packages from an earlier local install. Once the tarball downloads failed, the dependency directory contained nothing useful.

The Dockerfile separated dependency installation from source compilation. Step 3 compares that original sequence with the revised build.

The later vite: not found message didn't show whether npm had installed the package. Start with the dependency directory in the same environment that failed:

test -d node_modules && find node_modules -mindepth 1 -maxdepth 1 | head
test -x node_modules/.bin/vite

In this build, the first check found no installed packages. The second confirmed that the Vite executable was missing. A missing binary can also result from omitting development dependencies, so inspect the directory before changing NODE_ENV or the package manifest.

After a successful install, the directory listing should show package names. The test -x command prints nothing. Immediately after it, inspect its exit status:

echo $?

For a missing Vite executable, the expected result is:

1

A result of 0 means the executable exists and is executable.

Checkpoint: An empty dependency directory points to the install step. If other packages exist but Vite is missing, also check whether the install omitted development dependencies. These commands inspect the dependency directory without changing it.

Step 2: Find Where the Lockfile Would Download Packages

A generalized entry looked like this:

{
  "node_modules/example-package": {
    "version": "1.2.3",
    "resolved": "http://packages.internal.invalid/npm/example-package/-/example-package-1.2.3.tgz",
    "integrity": "sha512-<subresource-integrity-value>"
  }
}

The .invalid hostname above replaces the real internal hostname. The structure matches the historical lockfile.

npm documents resolved as the location from which it obtained a package. For a registry package, that value can be a tarball URL. The configured registry on the build runner therefore didn't solve this incident. The lockfile already told npm where to fetch each tarball.

I counted the affected entries in the historical file. Of 1,038 dependency entries, 528 carried the internal hostname. Each pointed at a host that didn't resolve in the external build environment.

The npm debug output pointed to an earlier failure. The hostname failures appeared before Exit handler never called!. That order mattered. The exit-handler message described how npm ended, while the failed downloads explained why the dependency tree was empty.

I inspected the lockfile as data instead of searching for one package name. This small script groups every HTTP resolved value by hostname:

const fs = require('node:fs')
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'))
const hosts = {}

for (const entry of Object.values(lock.packages ?? {})) {
  if (!entry.resolved?.startsWith('http')) continue
  const host = new URL(entry.resolved).host
  hosts[host] = (hosts[host] ?? 0) + 1
}

console.table(hosts)

That inventory showed that the problem affected 528 entries rather than one broken tarball. It also made the repair boundary clear. The package locations recorded in resolved needed to change, while versions and integrity values stayed the same. If the host list includes a private registry, stop here and identify which packages it serves before rewriting anything.

The historical inventory contained this row. The table uses the replacement hostname from the example:

Host Resolved URLs
packages.internal.invalid 528

Your hostnames and counts will depend on your lockfile. This inventory counts HTTP tarball locations, so its total can differ from the number of dependency entries.

Checkpoint: Identify the unreachable host and confirm which of its packages come from the public npm registry. Record the affected count before choosing a replacement prefix. A hostname count alone doesn't establish that rewriting those URLs is safe.

Step 3: Rewrite Only the Known Public-package URL Prefix

Regenerating the lockfile can mix a registry repair with dependency updates. Keeping the package versions and integrity values unchanged makes the repair easier to review.

The affected packages in this application came from the public npm registry. The development platform proxied them and preserved their integrity values. The historical repair replaced its URL prefix before the container install.

For this walkthrough, save the following as rewrite-lockfile.cjs. It changes only resolved fields with the approved prefix. It checks for missing integrity values and unexpected paths on the old host before writing the file.

const fs = require('node:fs')

const oldPrefix = 'http://packages.internal.invalid/npm/'
const newPrefix = 'https://registry.npmjs.org/'
const oldHostname = new URL(oldPrefix).hostname
const filename = 'package-lock.json'
const lock = JSON.parse(fs.readFileSync(filename, 'utf8'))

if (!lock.packages || typeof lock.packages !== 'object') {
  throw new Error('Expected a lockfile with a packages object (version 2 or 3)')
}

let rewritten = 0
for (const entry of Object.values(lock.packages)) {
  if (typeof entry.resolved !== 'string') continue
  if (!entry.resolved.startsWith(oldPrefix)) continue
  if (!entry.integrity) throw new Error('An affected package has no integrity value')
  entry.resolved = newPrefix + entry.resolved.slice(oldPrefix.length)
  rewritten += 1
}

for (const entry of Object.values(lock.packages)) {
  if (typeof entry.resolved !== 'string') continue
  if (!entry.resolved.startsWith('http')) continue
  if (new URL(entry.resolved).hostname === oldHostname) {
    throw new Error('The old hostname remains under an unexpected URL prefix')
  }
}

fs.writeFileSync(filename, JSON.stringify(lock, null, 2) + '\n')
console.log(`Rewrote ${rewritten} resolved URLs`)

When the dependency stage runs the script, its message should match the number of URLs that use the approved prefix. The small test project used to check this tutorial produced:

Rewrote 40 resolved URLs

That test project is separate from the historical application, which had 528 affected entries. Use the inventory for your own file as the expected count. A zero count on an already-repaired file is expected. If an unrepaired file produces zero, check the prefix.

The script doesn't establish that a package is public. Confirm that separately. npm's integrity check then verifies that the downloaded bytes match the lockfile.

Keep local dependencies and configuration files out of the build context with a .dockerignore:

node_modules
dist
.git
.env*
.npmrc

This example installs public packages. A private registry needs its own reachable endpoint and secure build-time authentication.

The first block below shows the affected instructions from the original Dockerfile. The second is the complete tutorial build. It uses Node 24 and the guarded install, so check compatibility with your own application.

Before: Original Install and Build Sequence

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

After: URL Rewrite and Dependency Checks

Use two build stages so source changes don't invalidate the dependency-install stage:

FROM node:24-slim AS dependencies
WORKDIR /app

COPY package.json package-lock.json rewrite-lockfile.cjs ./
RUN node rewrite-lockfile.cjs \
 && npm ci --include=dev \
 && test -x node_modules/.bin/vite

FROM node:24-slim AS build
WORKDIR /app

COPY . .
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=dependencies /app/package-lock.json ./package-lock.json
RUN npm run build

The revised Dockerfile makes each part of the install explicit:

  • The URL rewrite replaces the approved public-package prefix before npm ci downloads packages.

  • --include=dev makes the choice to install development dependencies explicit.

  • The Vite executable check stops the dependency stage if the install leaves Vite missing.

  • The separate dependency stage installs packages before the application build stage copies them and compiles the source.

The second stage copies the repaired lockfile after the application source. Otherwise, COPY . . would put the original lockfile back into the build image. The .dockerignore also prevents local node_modules from overwriting the dependencies from the first stage.

--include=dev includes Vite when it's a development dependency, even if the build environment sets NODE_ENV=production. This image is a build environment, so it needs the compiler and build tools. Choose a supported Node version compatible with your project.

The historical lockfile comparison found 528 changed fields, all named resolved. It found zero changed version or integrity fields and no remaining internal hostname. The example script above also leaves unrelated fields unchanged, although JSON formatting can change.

The rewrite changes only each affected resolved URL. Package versions and integrity values stay the same.

Figure 2. The portability repair changes package locations while preserving dependency identity.

The executable check makes the dependency stage fail if Vite is still missing. Docker stops at that stage instead of reaching the application build.

Checkpoint: Check the rewrite count against your inventory, confirm that the approved old hostname is gone, and compare versions and integrity values before and after the repair. The dependency stage must complete both npm ci --include=dev and the Vite executable check. Step 5 builds the image and verifies the application output.

Step 4: Apply the Guard at Every Independent Install

The continuous integration job and the container image builder ran separate installs. Fixing the workflow runner didn't change the npm ci inside the Dockerfile. The image build received the original repository files and created its own dependency layer in another environment.

The rewrite belongs immediately after the dependency files enter the install environment. Docker's cache guidance recommends installing dependencies before copying frequently changing source files. In the example, the dependency stage has that boundary. A source-only change rebuilds the application stage.

Apply the same guard before each independent install:

  • the continuous integration type-check install

  • the container build install

  • any runtime-stage install in a multi-stage Dockerfile

The repository lockfile enters the CI runner and container builder separately. Each environment rewrites and checks the URLs before running npm ci.

Figure 3. A workflow repair doesn't automatically repair the install inside a container build.

This placement also prevented a misleading partial fix. A type check could otherwise pass while the image build failed because the two jobs used the same lockfile in separate installs.

Step 5: Verify the Dependency Tree and the Trust Boundary

This repair was safe because the affected entries represented public npm packages behind a transparent development-platform proxy. The integrity strings remained present, and the rewrite didn't change any package version.

The same substitution would be unsafe for private packages, patched packages, or a registry that serves different bytes under the same package version. A portable build should keep those packages on a reachable private registry and configure authentication for that registry.

Before applying this pattern elsewhere, I would inventory every resolved hostname and separate public packages from private ones. I would then compare package versions and integrity values before and after the change. The build should fail if an unapproved hostname remains.

An npm CLI issue records the same Exit handler never called! message after package downloads from an internal registry timed out. That message doesn't identify the package location by itself. In this incident, the tarball URLs and failed hostname established the cause.

Build the image and verify the output:

docker build --progress=plain -t vite-install-check .
docker run --rm vite-install-check sh -c \
  'test -x node_modules/.bin/vite && test -s dist/index.html'

The build must pass npm ci, the Vite executable check, and npm run build. The final command checks the expected Vite output in the resulting image. Adapt the output check if your project uses a different directory. The image in this example compiles the application. Choose a separate serving stage if you need a production web server.

A successful minimal example doesn't validate every dependency in an existing application. Run the same build against the project you intend to ship. When a tool is missing, check the install result and its earlier errors before changing the build tool.

References



Read the whole story
alvinashcraft
16 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

The eMudhra CA compromise and ngrok: what's affected and what to do

1 Share
If you run the ngrok agent with its default configuration, your tunnels aren't affected by the eMudhra CA compromise. Here's which settings are, and what to do.
Read the whole story
alvinashcraft
29 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

What is OctaneJS? A compiler-first alternative to React

1 Share

React’s programming model has been hard to beat. Components, hooks, and unidirectional state have shaped how most frontend developers think about building UIs. But you pay for that model at runtime. React builds a virtual DOM on every state change and reconciles it against the previous tree. Much of that work is predictable, which means a compiler could do it ahead of time.

alt

Octane is a framework built on that idea. It’s an ahead-of-time (AOT) compiled JavaScript framework that keeps React’s programming model while dropping the runtime Virtual DOM.

In this article, we’ll build a task dashboard with Octane and use it to answer the practical questions: how does it handle async UI, dependency tracking, lists, and events?

By the end, you’ll understand how Octane differs from React, its trade-offs, and whether it’s worth trying.

What do you need to get started with OctaneJS?

If you want to try out the code in this article, you will need the following:

  • Node.js 22 or newer installed on your machine
  • Familiarity with React hooks and TypeScript
  • Basic experience with tools like Vite

What is OctaneJS?

Octane is an ahead-of-time (AOT) compiled JavaScript framework.

The project describes itself as “React’s programming model, compiled,” and it’s positioned as the successor to Inferno, Gannaway’s earlier React-like renderer.

In practice, that means Octane supports the APIs you already know (useState, useEffect, useRef, useContext, and Suspense) while skipping the runtime Virtual DOM entirely.

Instead of reconciling an in-memory tree on every state change, the compiler analyzes your components at build time and emits direct DOM manipulation instructions.

Five architectural decisions drive the framework’s performance:

  • No virtual DOM: Where React builds, diffs, and reconciles an in-memory tree on every state change, Octane’s compiler produces granular DOM update instructions during the build phase. This reduces CPU work and garbage collection activity during updates
  • Inferred dependency lists: The compiler automatically detects reactive closure bindings for hooks like useEffect, useMemo, and useCallback, so you don’t hand-maintain dependency arrays
  • Flexible hook execution: The compiler assigns each hook call a stable tracking slot at build time, so hooks can sit behind conditional paths and early returns
  • TSRX control flow: Template directives like @for, @if, and @try make the structure of the UI explicit to the compiler, replacing inline .map() calls and nested ternaries
  • Sibling-scoped styles: CSS <style> blocks next to your component markup get automatic scope boundaries without external post-processors

Octane is currently beta software. The documentation recommends pinning versions for real projects.

We’ll come back to what that means for adoption.

How is OctaneJS different from React?

React gives developers a familiar component and state model, but some of that flexibility comes with runtime work.

Octane’s approach is to preserve the programming model while using compilation to determine more of that work ahead of time.

Here are the differences:

How does OctaneJS replace React’s virtual DOM?

React performs reconciliation at runtime.

It builds a virtual tree, compares it against the previous one, and applies the differences to the DOM. Octane does this comparison once, in the compiler.

The build output knows exactly which DOM nodes depend on which state, so an update targets specific nodes directly instead of diffing anything.

How does OctaneJS handle React hooks?

React tracks hooks using an internal call-order array, which is why the Rules of Hooks exist.

Hooks must run in the same sequence every render so their state doesn’t get mixed up.

Octane replaces runtime array indices with static compiler slots.

Each hook call site gets a stable slot assigned during the build, independent of execution order.

That’s the mechanism behind the most visible difference.

In React, this is invalid:

if (editable) {
  useEffect(() => {
    // ...
  });
}

In Octane, the compiler accepts the same code:

if (editable) {
  useEffect(() => {
    // ...
  });
}

How does OctaneJS infer hook dependencies?

In React, omitting the dependency array in useEffect or useMemo means the callback runs on every render. In Octane, omitting the array tells the compiler to analyze the lexical scope and infer the reactive bindings itself.

You can still write explicit arrays where you want them, but the common case is inference.

How does OctaneJS handle DOM events?

React attaches events at the root and dispatches them through a synthetic event system.

Octane attaches native browser events directly to elements.

Handlers receive native DOM events, so onInput, onChange, and onBlur behave the way they do in plain JavaScript.

What is TSRX in OctaneJS?

Octane uses TSRX, a superset of TSX that adds control flow directives directly to your markup.

Instead of embedding runtime .map() calls and ternary expressions inside JSX tags, TSRX introduces block directives that model logical branches:

  • @if (condition) { ... } @else { ... }: Conditional rendering paths
  • @for (const item of items; key item.id) { ... } @empty { ... }: Keyed collection rendering with an empty-list fallback
  • @switch (val) { @case (a) { ... } @default { ... } }: Constant value branching
  • @try { ... } @pending { ... } @catch (err) { ... }: Error boundaries and asynchronous data states

When the compiler sees @for, it knows it’s generating a keyed list region and can emit targeted node insertions and removals for it.

An @if tells it the two branches are alternative shapes of the same region, so it can prepare both.

A runtime .map() or ternary forces the framework to figure that structure out during rendering.

For a deeper dive into the syntax itself, check out LogRocket’s article on TypeScript Render Extensions for React beyond JSX.

How to build a task dashboard with OctaneJS

Now let’s build something to see these features in practice.

In the course of building the task dashboard, we will answer the following questions about Octane:

  • How does it handle async UI?
  • How does it track dependencies?
  • How does it handle lists?
  • How does it handle events?
  • How does it handle CSS?

To create a new Octane project, open your terminal and run:

npm create octane@latest app-name

The task dashboard application uses the following folder layout:

  • components/
    • TaskDashboard.tsrx
    • TeamPage.tsrx
  • services/
    • api.ts
  • App.tsrx

We use an in-memory store for the mock API, shared between requests so data persists.

This is the app running in the browser:

alt

How to set up an API service for an OctaneJS app

We’ll start by creating the API service to handle asynchronous task operations.

In your workspace, create a file at src/services/api.ts and add the following code:

export type Task = {
  id: number;
  title: string;
  completed: boolean;
};

export function fetchTasks(teamId: number): Promise<Task[]> {
  return fetch(`/api/teams/${teamId}/tasks`).then(res => {
    if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
    return res.json() as Promise<Task[]>;
  });
}

export function createTask(teamId: number, title: string): Promise<Task> {
  return fetch(`/api/teams/${teamId}/tasks`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title }),
  }).then(res => {
    if (!res.ok) throw new Error(`Failed to create task (${res.status})`);
    return res.json() as Promise<Task>;
  });
}

export function updateTask(teamId: number, taskId: number, updates: Partial<Task>): Promise<Task> {
  return fetch(`/api/teams/${teamId}/tasks/${taskId}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(updates),
  }).then(res => {
    if (!res.ok) throw new Error(`Failed to update task (${res.status})`);
    return res.json() as Promise<Task>;
  });
}

export function deleteTask(teamId: number, taskId: number): Promise<{ success: boolean }> {
  return fetch(`/api/teams/${teamId}/tasks/${taskId}`, {
    method: 'DELETE',
  }).then(res => {
    if (!res.ok) throw new Error(`Failed to delete task (${res.status})`);
    return res.json();
  });
}

By returning raw Promises from these functions, we let the UI delegate loading and error states to template-level compiler blocks.

How does OctaneJS handle asynchronous UI?

Next, we’ll build the presentation container in src/components/TeamPage.tsrx. This component manages loading and error states around our dashboard while allowing users to dynamically select a team.

Create src/components/TeamPage.tsrx and add the following code:

import { useState } from 'octane';
import { TaskDashboard } from './TaskDashboard';

export function TeamPage() @{
  const [teamId, setTeamId] = useState(3);

  <>
    <div class="team-selector-bar">
      <label for="team-select" class="team-label">Select Team:</label>
      <select
        id="team-select"
        class="team-select"
        value={teamId}
        onChange={event => setTeamId(Number(event.currentTarget.value))}
      >
        <option value={1}>Team 1 (Frontend)</option>
        <option value={2}>Team 2 (Backend)</option>
        <option value={3}>Team 3 (Fullstack)</option>
      </select>
    </div>

    @try {
      <TaskDashboard teamId={teamId} />
    } @pending {
      <p class="loading">Loading team tasks...</p>
    } @catch (error) {
      <p class="error">Could not load tasks: {error.message as string}</p>
    }

    <style>
    /////
    </style>
  </>
}

In React, displaying loading and fallback UI means wrapping components in Suspense boundaries and writing separate error boundary classes. Octane’s TSRX declares these behaviors natively with @try, @pending, and @catch directives.

Because the directives are compiler-visible, the framework knows these are the three states of the same region at build time. The compiler also turns the sibling <style> block into unique hash classes, scoping styles strictly to this component without post-processors.

How does OctaneJS manage state, dependencies, and lists?

Now let’s build the core dashboard interface in src/components/TaskDashboard.tsrx.

This component handles input filtering, synchronized state, task actions, and directive-based list rendering.

Create src/components/TaskDashboard.tsrx and paste the following code:

import { useState, useLinkedState, use, useEffect } from 'octane';
import { fetchTasks, createTask, updateTask, deleteTask, type Task } from '../services/api';

export function TaskDashboard({ teamId }: { teamId: number }) @{
  const fetched = use(fetchTasks(teamId));
  const [tasks, setTasks] = useLinkedState(fetched, source => [...source]);
  const [query, setQuery] = useState("");
  const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
  const [newTaskTitle, setNewTaskTitle] = useState("");
  const [editingId, setEditingId] = useState<number | null>(null);
  const [editingTitle, setEditingTitle] = useState("");

  const handleAddTask = (e: Event) => {
    e.preventDefault();
    const title = newTaskTitle.trim();
    if (!title) return;
    setNewTaskTitle("");
    createTask(teamId, title).then(newTask => {
      setTasks([...tasks, newTask]);
    });
  };

  const handleToggleComplete = (task: Task) => {
    const updatedCompleted = !task.completed;
    setTasks(tasks.map(t => (t.id === task.id ? { ...t, completed: updatedCompleted } : t)));
    updateTask(teamId, task.id, { completed: updatedCompleted });
  };

  const handleStartEdit = (task: Task) => {
    setEditingId(task.id);
    setEditingTitle(task.title);
  };

  const handleSaveEdit = (taskId: number) => {
    const title = editingTitle.trim();
    if (title) {
      setTasks(tasks.map(t => (t.id === taskId ? { ...t, title } : t)));
      updateTask(teamId, taskId, { title });
    }
    setEditingId(null);
  };

  const handleCancelEdit = () => {
    setEditingId(null);
  };

  const handleDelete = (taskId: number) => {
    setTasks(tasks.filter(t => t.id !== taskId));
    deleteTask(teamId, taskId);
  };

  const visible = tasks.filter(task => {
    const matchesQuery = task.title.toLowerCase().includes(query.toLowerCase());
    if (!matchesQuery) return false;
    if (filter === 'active') return !task.completed;
    if (filter === 'completed') return task.completed;
    return true;
  });

  const remainingCount = tasks.filter(task => !task.completed).length;

  useEffect(() => {
    document.title = `${remainingCount} ${remainingCount === 1 ? 'task' : 'tasks'} remaining`;
  });

  <>
    <form class="add-task-form" onSubmit={handleAddTask}>
      <input
        type="text"
        class="add-input"
        placeholder="What needs to be done?"
        value={newTaskTitle}
        onInput={event => setNewTaskTitle(event.currentTarget.value)}
      />
      <button type="submit" class="btn btn-primary" disabled={!newTaskTitle.trim()}>
        Add Task
      </button>
    </form>

    <div class="dashboard-controls">
      <input
        type="search"
        class="search-input"
        placeholder="Filter tasks by text..."
        value={query}
        onInput={event => setQuery(event.currentTarget.value)}
      />

      <div class="filter-tabs">
        <button
          class={{ 'filter-btn': true, active: filter === 'all' }}
          onClick={() => setFilter('all')}
        >
          All ({tasks.length})
        </button>
        <button
          class={{ 'filter-btn': true, active: filter === 'active' }}
          onClick={() => setFilter('active')}
        >
          Active ({remainingCount})
        </button>
        <button
          class={{ 'filter-btn': true, active: filter === 'completed' }}
          onClick={() => setFilter('completed')}
        >
          Completed ({tasks.length - remainingCount})
        </button>
      </div>
    </div>

    <ul class="task-list">
      @for (const task of visible; key task.id) {
        <li class={{ 'task-item': true, done: task.completed }}>
          <input
            type="checkbox"
            class="task-checkbox"
            checked={task.completed}
            onChange={() => handleToggleComplete(task)}
          />

          @if (editingId === task.id) {
            <input
              type="text"
              class="edit-input"
              value={editingTitle}
              onInput={event => setEditingTitle(event.currentTarget.value)}
              onBlur={() => handleSaveEdit(task.id)}
              onKeyDown={event => {
                if (event.key === 'Enter') handleSaveEdit(task.id);
                else if (event.key === 'Escape') handleCancelEdit();
              }}
              autoFocus
            />
          } @else {
            <span
              class="task-title-text"
              onClick={() => handleStartEdit(task)}
              title="Click to edit"
            >
              {task.title as string}
            </span>
          }

          <button
            type="button"
            class="delete-btn"
            onClick={() => handleDelete(task.id)}
            title="Delete task"
          >
            ✕
          </button>
        </li>
      } @empty {
        <li class="empty">
          {query ? `No tasks match "${query as string}"` : 'No tasks in this list'}
        </li>
      }
    </ul>

    <div class="dashboard-footer">
      <span>{remainingCount} {remainingCount === 1 ? 'task' : 'tasks'} remaining</span>
    </div>

    <style>
    /////
    </style>
  </>
}

Let’s walk through how this component works, focusing on the parts where the compiler takes over work you’d normally do yourself:

  1. Async loading with use(): Evaluates our asynchronous API call. Because the compiler tracks the teamId variable reference, it caches results and refetches when teamId changes, so there’s no manual useEffect watcher and no stale request race to guard against
  2. State synchronization with useLinkedState: Manages a mutable, localized copy of fetched. When the source data refreshes with a new array, useLinkedState updates the internal state tracking automatically, so the local copy stays in sync without a syncing effect
  3. Native inputs with onInput: Binds input state using direct DOM mechanics, skipping React’s synthetic event delegation layer. Note the value bindings: the compiler links them to state and updates the DOM properties directly
  4. Automated dependency tracking: The useEffect hook omits a manual dependency array. The compiler scans the closure scope, identifies the remainingCount reference, and manages execution triggers automatically
  5. Template directives @for and @empty: The compiler transforms these into direct, fast-path node mutations keyed on task.id, while @empty provides a declarative fallback when the filtered list is empty

Can you adopt OctaneJS without rewriting your React app?

For most teams, a framework is only practical if adoption can be incremental. Octane provides two compatibility bridges for exactly that.

To render a React component inside an Octane component, use the ReactCompat bridge:

import { ReactCompat } from 'octane/react';
import { ReactCalendar } from 'react-calendar';

export function Dashboard() @{
  <div>
    <h1>My Octane App</h1>
    <ReactCompat>
      <ReactCalendar />
    </ReactCompat>
  </div>
}

To run an Octane component inside an existing React codebase instead, use the OctaneCompat wrapper:

import { OctaneCompat } from 'octane/react';
import { FastTaskGrid } from './FastTaskGrid.tsrx';

export function ReactApp() {
  return (
    <div>
      <ReactHeader />
      <OctaneCompat>
        <FastTaskGrid />
      </OctaneCompat>
    </div>
  );
}

OctaneJS vs. React: Features, trade-offs, and compatibility

Here’s a trade-off comparison of the two frameworks:

Feature React Octane
Rendering model Runtime reconciliation Compiler-generated DOM updates
Hooks React hooks React-compatible hooks
Hook ordering Rules of Hooks Compiler-managed slots
Dependency arrays Explicit in common cases Compiler can infer them
Events React event system Native DOM events
Template syntax JSX/TSX JSX/TSX + TSRX
React interoperability Native ReactCompat / OctaneCompat
Project maturity Established Beta

What does OctaneJS change compared with React?

  • Virtual DOM reconciliation at runtime
  • The Rules of Hooks constraint
  • Manual dependency arrays in common cases
  • React’s synthetic event abstraction

What are the limitations of using OctaneJS?

  • A required compiler step and its build tooling
  • TSRX syntax alongside your TSX
  • Different event semantics (native DOM events)
  • A younger ecosystem with fewer integrations proven in production
  • Beta-software risk, including API changes between versions

Conclusion

Octane takes a familiar React programming model and pushes more of the work into the compiler.

It is worth adopting if your application has real rendering performance constraints and your team is comfortable with a newer, compiler-driven approach to React. Its ability to optimize rendering automatically, combined with incremental adoption through OctaneCompat, makes it a compelling choice for performance-sensitive applications.

However, you should not choose Octane simply because it is newer or promises better performance. If your application already performs well, depends heavily on the React ecosystem, or requires a highly stable production stack, stick with React. The maturity and ecosystem benefits outweigh Octane’s potential gains in those cases.

The post What is OctaneJS? A compiler-first alternative to React appeared first on LogRocket Blog.

Read the whole story
alvinashcraft
41 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Moving to Angular Zoneless

1 Share

Instead of change detection after every asynchronous task, Zoneless Angular applications use Signals to detect relevant state changes. Here’s how to set that up greenfield and what to watch for as you migrate to Zoneless.

If you have worked with Angular, you have probably seen components react and update the UI after a click, timer or HTTP request without calling detectChanges(). Angular has provided this convenience for years, so you usually do not need to think about what makes it work.

ZoneJS and Angular’s NgZone make this possible, and we can make reactive apps easy, but NgZone makes Angular check more than necessary. As your app grows, you may start asking a simple question: can Angular update only the views that really changed?

Modern Angular gives you that option with Zoneless change detection. Signals and other Angular APIs can tell Angular when a view needs an update. This lets you build applications without depending on NgZone to watch every asynchronous task.

Today, let’s build together a small product dashboard with Angular 22 and Progress Kendo UI for Angular. First, we will learn how the old model works. Then we will use Signals and httpResource to build a Zoneless dashboard. Finally, we will review common problems that can appear during a Zoneless migration.

Let’s start with what made the old model work: NgZone and ZoneJS.

What Are NgZone and ZoneJS?

When we talk about NgZone, it’s a service that connects Angular to the ZoneJS library. ZoneJS patches many browser APIs, including timers, events and promises. When one of these operations finishes, Angular can use that notification to schedule change detection.

Think of ZoneJS as a central alarm. It does not know which component changed. As a result, Angular may check a large part of the component tree after the alarm rings. This is easy to use because developers do not always need to tell Angular that state changed.

For example, this code changes a normal property from a timer:

export class NotificationComponent {
  badgeCount = 0;

  ngOnInit(): void {
    setInterval(() => {
      this.badgeCount++;
    }, 3000);
  }
}

With ZoneJS, the timer can trigger Angular change detection, and the badge updated property can appear in the template even though the component did not explicitly notify Angular. That convenience has a cost, especially in larger applications:

  • Angular can perform checks that do not produce a visible change.
  • ZoneJS injects runtime code into the application.
  • Stack traces can include framework scheduling details that make debugging painful.

The performance cost depends on the application. Zoneless does not promise that every application will become faster. It makes change detection more explicit and can reduce unnecessary work when the application exposes its state clearly.

We have seen why Angular used NgZone and what ZoneJS added. Now, let’s look at how Angular knows that a Zoneless view needs an update.

How Does Zoneless Change Detection Work?

Zoneless Angular does not need NgZone to watch browser tasks. Instead, Angular schedules change detection when one of its APIs sends a notification. Examples include a signal read by a template, a bound event listener, a component input and ChangeDetectorRef.markForCheck(). Signals are the most common option. They store state and tell Angular which views use it.

The difference is simple. ZoneJS can trigger a broad check after a timer, event or promise, but Zoneless Angular reacts to an Angular notification and updates the views connected to that state. Not every value must become a signal(). However, values used by a template must change through a mechanism that Angular can observe.

That is the idea we will use in the dashboard: a user event changes a signal, the resource loads new data and the template updates.

Build a Zoneless Product Dashboard

Let’s build a small product dashboard with a category filter and a Kendo UI for Angular Grid. Keeping the example small makes it easier to follow the change detection path.

Step 1: Create the Angular Project

Create the project with Angular CLI. Run this command in a terminal:

npx @angular/cli@22 new zoneless-kendo-dashboard --standalone --style=scss --ssr=false
cd zoneless-kendo-dashboard

The first command creates an Angular 22 project. It includes standalone components and SCSS, and it disables server-side rendering. The second command moves into the project folder. You should now have files such as src/app/app.ts, src/app/app.html, andsrc/app/app.scss`.

Start the new application to check that the project works before continuing:

ng serve

Open http://localhost:4200 in your browser. You should see the default Angular application. Stop the server with Ctrl+C before continuing.

New Angular projects use the defaults for that Angular version. Before using the next step in an existing project, check its Angular version and the generated app.config.ts.

Next, check the change detection configuration Angular generated.

Zoneless in Angular 22

In this example, you are using Angular 22, which uses zoneless change detection by default. The generated app.config.ts does not need a provideZonelessChangeDetection() call. The project also does not include zone.js as a dependency.

import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
    provideHttpClient(),
  ],
};

We add provideHttpClient() because this dashboard will use httpResource to call the Fake Store API. provideHttpClient() is an HTTP provider, not a Zoneless provider, but if your application does not make HTTP requests, you can leave it out.

What About Older Angular Versions?

This section is only for older versions like Angular 20, because not every project uses the latest Angular version. Angular 20 and later support opting into Zoneless with provideZonelessChangeDetection(). New projects created with Angular 21 and later use Zoneless by default, but existing applications still need a migration review.

For an Angular 20 application that still uses ZoneJS, add the provider in app.config.ts, then review the project configuration. If you use an older Angular version, check its version-specific migration guide because the provider and setup can be different:

import { provideZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
  ],
};

After checking the application, tests and third-party libraries, remove zone.js and zone.js/testing from angular.json or the polyfills configuration. Only then remove the package:

npm uninstall zone.js

Now you know how to set up Zoneless for the old version; now let’s continue with our project in Angular 22 and add Kendo UI!

Step 2: Install Kendo UI Components

Now that the Angular project is ready, add the UI components that users will use. Kendo UI will provide the category selector and product grid, so install the packages from the project folder:

npm install @progress/kendo-angular-grid @progress/kendo-angular-dropdowns
npm install @progress/kendo-theme-default @angular/animations @angular/localize

Check Kendo UI for Angular compatibility documentation when choosing package versions. Kendo UI components require Angular’s animation and localization runtimes, so install @angular/animations and @angular/localize as part of the setup.

Because Kendo UI uses Angular localization internally, add the localize runtime as a build polyfill in angular.json:

{
  "polyfills": [
    "@angular/localize/init"
  ]
}

Then load the Kendo UI theme in src/styles.scss:

@import '@progress/kendo-theme-default/dist/all.css';

Kendo UI gives you ready-to-use components such as the Angular DropDownList and Grid. Before you use all their features, review the Angular bundle budget. The application can compile successfully even when its bundle exceeds the default budget, so update angular.json after measuring the result:

{
  "type": "initial",
  "maximumWarning": "5MB",
  "maximumError": "6MB"
}

This change gives the Kendo UI Grid and DropDownList enough space within the bundle budget. Do not increase the budget in production without first measuring the bundle. Review imports, themes and lazy loading as the application grows. In this project, the generated bundle is about 4.4 MB, so the default 500kB warning and 1MB error limits are too low for these components.

The project is ready for the dashboard UI. Next, connect the product request to a signal so a category change can load new data.

Step 3: Fetch Products with httpResource

The UI dependencies are in place, but the grid still needs data. Use httpResource to connect the HTTP request to Angular’s reactive APIs. If the signal read by the request function changes, the resource starts a new request and exposes loading, error and value state.

Create src/app/product.service.ts. Run this command from the project folder to generate the file:

ng generate service product

Replace the generated service with this code:

import { httpResource } from '@angular/common/http';
import { Service, signal } from '@angular/core';

export interface Product {
  id: number;
  title: string;
  price: number;
  category: string;
  description: string;
}

@Service()
export class ProductService {
  readonly selectedCategory = signal('all');

  readonly productsResource = httpResource<Product[]>(() => {
    const category = this.selectedCategory();

    return category === 'all'
      ? 'https://fakestoreapi.com/products'
      : `https://fakestoreapi.com/products/category/${encodeURIComponent(category)}`;
  });
}

The service does not inject HttpClient because httpResource handles the request. The signal is read inside the resource function, so changing the selected category changes the resource URL. This example uses @Service() because it is a root-level service that uses inject().

The service now owns the reactive product request. That gives the component one clear source for its server state.

Step 4: Connect Kendo UI Components with Signals

The service is ready. Now we connect the Kendo UI DropDownList (category filter) and Grid (product list) to the signals from ProductService. The template reads signals directly—[value]="selectedCategory()" and [data]="products()"—so when a signal changes, Kendo UI updates without NgZone.

The Kendo UI Grid gives you virtual scrolling, sorting, filtering and column resizing out of the box, all reactive to signal changes. The DropDownList handles keyboard navigation, filtering and accessibility without extra code.

Think of it like a direct wire: the signal is the switch, the Kendo UI component is the light. When you flip the switch, the light turns on. No central alarm (ZoneJS) needed.

The current Angular CLI creates src/app/app.ts, src/app/app.html, and src/app/app.scss. Update these files separately. This matches the structure the CLI generates and shows where each part of the component belongs.

import {
  Component,
  computed,
  effect,
  inject,
} from '@angular/core';
import { DropDownListModule } from '@progress/kendo-angular-dropdowns';
import { GridModule } from '@progress/kendo-angular-grid';
import { ProductService } from './product.service';

@Component({
  selector: 'app-root',
  imports: [DropDownListModule, GridModule],
  templateUrl: './app.html',
  styleUrl: './app.scss',
})
export class App {
  private readonly productService = inject(ProductService);

  readonly categories = [
    'all',
    'electronics',
    'jewelery',
    "men's clothing",
    "women's clothing",
  ];

  readonly selectedCategory = this.productService.selectedCategory;
  readonly productsResource = this.productService.productsResource;
  readonly products = computed(() => this.productsResource.value() ?? []);

  constructor() {
    effect(() => {
      document.title = `Products - ${this.selectedCategory()}`;
    });
  }

  onCategoryChange(category: string): void {
    this.productService.selectedCategory.set(category);
  }
}

Now replace the content of src/app/app.html with the dashboard template:

<main class="container">
  <header>
    <h1>Angular Zoneless Product Dashboard</h1>
    <p>Signals, httpResource, and Kendo UI working together.</p>
  </header>

  <section class="controls" aria-labelledby="filter-title">
    <h2 id="filter-title">Filter products</h2>
    <kendo-dropdownlist
      aria-label="Filter by category"
      [data]="categories"
      [value]="selectedCategory()"
      (valueChange)="onCategoryChange($event)"
    ></kendo-dropdownlist>
  </section>

  <p class="category-summary">
    Available categories:
    @for (category of categories; track category) {
      <span>{{ category }}</span>
    }
  </p>

  @if (productsResource.isLoading()) {
    <p class="status">Loading products...</p>
  } @else if (productsResource.error()) {
    <p class="status error" role="alert">
      The products could not be loaded. Try again later.
    </p>
  } @else {
    <kendo-grid [data]="products()" [height]="400">
      <kendo-grid-column field="id" title="ID" [width]="70" />
      <kendo-grid-column field="title" title="Product" />
      <kendo-grid-column field="category" title="Category" [width]="160" />
      <kendo-grid-column field="price" title="Price" format="{0:c}" [width]="120" />
    </kendo-grid>
}
}

Let’s take a look at the HTML code. The DropDownList reads selectedCategory() with [value]. The Grid reads products() with [data]. And the (valueChange) event writes back to the signal. That is the whole loop. Read the signal, update UI, write signal, done!

The effect() syncs the category to the browser title. A side effect outside the template, but still driven by a signal.

Next, replace the content of src/app/app.scss with the dashboard styles:

.container {
  max-width: 900px;
  margin: 2rem auto;
  padding: 0 1rem;
  font-family: system-ui, sans-serif;
}

header {
  border-bottom: 2px solid #d9e2ec;
  margin-bottom: 2rem;
  padding-bottom: 1rem;
}

.controls {
  align-items: center;
  display: flex;
  gap: 1rem;
  margin-bottom: 1rem;
}

.category-summary span {
  margin-right: 0.75rem;
}

.status {
  padding: 2rem;
  text-align: center;
}

.error {
  color: #a61b1b;
}

The @if block reacts to resource state, while @for renders the category summary. The key point is that the template reads Signals. When selectedCategory changes, the resource and the affected view receive Angular notifications without relying on NgZone.

Run the application by running the following command:

ng serve

Open http://localhost:4200. You should first see the loading message, followed by the product grid. Select all in the dropdown and confirm that the loading message appears again before the filtered products are displayed.

Angular Zoneless Product Dashboard filtering

Done!! Our flow is working from the signal to the resource and then to the template. But my next question is: how do we test this flow without leaving an HTTP request pending?

Test Zoneless Components

In a Zoneless test, you need to wait for Angular’s scheduled work and control asynchronous dependencies. A pending HTTP request is not stable work, so flush it through HttpTestingController. This lets you test the same signal-to-view path without depending on the real API.

Here is a focused Vitest test for the initial product request in src/app/app.spec.ts. The test uses provideHttpClientTesting() to control the request.

The Zoneless provider is included in TestBed to make the setup explicit, even though Angular 22 enables it by default in a new application:

The following snippet copy and replace the app.spec.ts file:

import { provideHttpClient } from '@angular/common/http';
import {
  HttpTestingController,
  provideHttpClientTesting,
} from '@angular/common/http/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { App } from './app';

describe('App in a zoneless application', () => {
  let fixture: ComponentFixture<App>;
  let httpTesting: HttpTestingController;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [App],
      providers: [
        provideZonelessChangeDetection(),
                provideAnimationsAsync(),
        provideHttpClient(),
        provideHttpClientTesting(),
      ],
    }).compileComponents();

    fixture = TestBed.createComponent(App);
    httpTesting = TestBed.inject(HttpTestingController);
    fixture.detectChanges();
  });

  afterEach(() => {
    httpTesting.verify();
  });

  it('renders products after the HTTP request settles', async () => {
    const request = httpTesting.expectOne(
      'https://fakestoreapi.com/products',
    );

    request.flush([
      {
        id: 1,
        title: 'Test product',
        price: 10,
        category: 'electronics',
        description: 'A product for the test',
      },
    ]);

    await fixture.whenStable();

    expect(fixture.nativeElement.textContent).toContain('Test product');
  });

  it('loads products when the category changes', async () => {
    const initialRequest = httpTesting.expectOne(
      'https://fakestoreapi.com/products',
    );
    initialRequest.flush([]);
    await fixture.whenStable();

    fixture.componentInstance.onCategoryChange("men's clothing");
    fixture.detectChanges();

    const request = httpTesting.expectOne(
      "https://fakestoreapi.com/products/category/men's%20clothing",
    );

    request.flush([
      {
        id: 2,
        title: 'Test clothing product',
        price: 20,
        category: "men's clothing",
        description: 'A clothing product for the test',
      },
    ]);

    await fixture.whenStable();

    expect(fixture.nativeElement.textContent).toContain('Test clothing product');
  });
});

What does this code do? Well,
fixture.detectChanges() starts the initial render and request. flush() completes the controlled request. And whenStable() waits for Angular to finish the resulting render work. In other words, call whenStable() after you resolve the HTTP request instead of leaving it pending.

To run our test, use npm run test:

Code screen zoneless-kendo-dashboard test run

Yeah!! Our test now covers the initial request and a category change. So we have our app with Zoneless and testing, but this was a greenfield. Let me share with you the common migration problems I have encountered most often.

Common Migration Issues

Before we finish, let me share some of the most common problems I have found while migrating real Angular applications to Zoneless. This is not a complete list of every possible problem. These are the cases I’ve seen in real projects, and they are a good place to start when you review your own application.

These examples cover different migration boundaries. The practical fix depends on where the state enters the application.

A Timer Changes State But the UI Does Not Update

A normal property changed by setInterval() does not notify Angular in a Zoneless application. Use a signal for state read by the template, and clean up the timer when the component is destroyed:

import { Component, DestroyRef, inject, signal } from '@angular/core';

@Component({
  selector: 'app-notification',
  template: '{{ badgeCount() }}',
})
export class NotificationComponent {
  readonly badgeCount = signal(0);
  private readonly destroyRef = inject(DestroyRef);

  constructor() {
    const timer = setInterval(() => {
      this.badgeCount.update((count) => count + 1);
    }, 3000);

    this.destroyRef.onDestroy(() => clearInterval(timer));
  }
}

For RxJS streams, use takeUntilDestroyed() when a manual subscription must be tied to the component lifecycle.

The timer example is the simplest case: template state needs an Angular notification.

A Manual RxJS Subscription Leaves a Stale View

An Observable emission does not automatically make a normal component property reactive. Prefer toSignal() when the value belongs in a template:

import { Component, Service, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval, map } from 'rxjs';

@Service()
export class WeatherService {
  readonly temperature$ = interval(5000).pipe(map(() => 72));
}

@Component({
  selector: 'app-weather',
  template: '<p>{{ temperature() }} F</p>',
})
export class WeatherComponent {
  private readonly weatherService = inject(WeatherService);

  readonly temperature = toSignal(this.weatherService.temperature$, {
    initialValue: 72,
  });
}

toSignal() subscribes to the Observable, exposes the latest value as a signal, and connects the value to the component lifecycle. If you need a manual subscription to perform an action in code, use takeUntilDestroyed() and update a signal inside the callback.

This keeps the stream connected to the template without calling manual change detection. It is a useful pattern when an existing application already uses RxJS.

A Third-Party Widget Does Not Notify Angular

Maps, charts, editors and older wrappers may emit callbacks outside Angular’s reactive model. First, check whether the package has a current Angular integration. If it does not, isolate the integration and update a signal at the boundary:

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-map',
  template: '<p>Selected location: {{ selectedLocation() }}</p>',
})
export class MapComponent {
  readonly selectedLocation = signal('');

  onMapSelectionChanged(selection: string): void {
    this.selectedLocation.set(selection);
  }
}

Because the template reads selectedLocation(), updating the signal already notifies Angular in a Zoneless application. If an external callback updates ordinary component state instead, use markForCheck() at that boundary. I use detectChanges() only for a narrow integration boundary, because it runs change detection immediately and should not become the default solution throughout the application.

These are the boundaries I have encountered most often. They are not the only cases, but they are a useful starting point for a Zoneless migration.

A Practical Migration Path

When moving an existing application from ZoneJS to Zoneless, first check the Angular version and read the migration guide for that version. This shows whether the application needs provideZonelessChangeDetection() explicitly. Before removing anything, search the project for NgZone, zone.js, zone.js/testing and code that depends on onStable or onMicrotaskEmpty.

Next, follow the state that each feature exposes to its template. Please pay special attention to values changed by timers, external callbacks and manual subscriptions. If a normal property is doing that work, move it to signal(), computed(), toSignal() or another Angular notification mechanism. Also check third-party libraries such as maps, charts, dialogs, date pickers and editors, because their callbacks may need an explicit integration boundary.

Keep the migration small enough to test as you go. Once the application and its dependencies no longer rely on ZoneJS, remove it from the project configuration and uninstall the package. Finally, measure the result with the browser performance tools. Do not assume that Zoneless alone will improve every metric.

Recap

The main idea is this: Zoneless applications do not depend on NgZone and ZoneJS to schedule change detection after every asynchronous task. Instead, the application exposes template state through Signals and other Angular APIs that can tell Angular when relevant state changes.

In my experience, an incremental migration is the most practical approach. Move one feature, test it, inspect its integrations and then continue to the next feature. This makes the application’s real change detection dependencies easier to see without turning the whole migration into one large debugging session.

Now it is your turn to try the same model with a small piece of client-side state.

Open Challenge

Add a search input to the dashboard. Keep the query in a signal(), and use computed() to show the matching products. Leave the category request as it is, so the resource manages server state while Signals manage local UI state.

Happy coding!

Feel free to read extra docs to have a full overview. :)

And remember, Kendo UI for Angular, one of the best Angular libraries comes with a free 30-day trial. So go on:

Try Now

Read the whole story
alvinashcraft
54 seconds ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories