An experimental bridge for React + C# MVVM desktop applications.
JsBridgeDotnet is a prototype exploring a specific approach: building desktop applications using React for the UI layer and C#/.NET for the business logic, connected through a simple MVVM-style bridge.
The idea is straightforward: what if we could treat a React frontend like a XAML view, with full two-way data binding to .NET ViewModels? No REST APIs, no complex IPC—just direct method calls and observable properties.
Building modern desktop UIs with WPF/XAML can feel limiting compared to web development that have almost infinite controls and styles. Meanwhile, Electron-style apps often become bloated and lose access to native platform capabilities.
This prototype explores a middle ground:
- Keep the React ecosystem (components, hooks, npm packages) for UI development
- Retain .NET's strengths for business logic, file system access, native APIs
- Use MVVM patterns you already know from WPF development
- Avoid the complexity of full client-server architectures for desktop apps
The potential for cross platform is great, as it could easily extend to :
- Maui.net + HybridWebView + React Native for web (Exploit the infinite controls ecosystem of React Native + React)
- SignalR for a web app
1. Services as ViewModels
.NET services decorated with [JsService] become accessible from React.
[JsService("Timer")]
public partial class TimerService : ObservableObject
{
[ObservableProperty] private bool isRunning;
}2. React as a View Layer Instead of fighting with XAML styling and limited component libraries, use React's ecosystem directly:
const [running, setRunning] = useObservableProperty(timerService, 'IsRunning');
return (
<h2>
{running ? '⏱️ Timer Running' : '⏹️ Timer Ready'} // <-- Reactive by Default !
</h2>
)3. Simple Dependency Injection Register services in .NET's DI container, access them from JavaScript by name. No manual service location or complex initialization.
Bridge your WPF application with React seamlessly. This guide walks you through building a simple Timer application that demonstrates the two-way communication between .NET and JavaScript.
A WPF app with an embedded React UI that can control a .NET service. The React frontend will be able to start a timer running in the .NET backend and receive real-time status updates.
- Visual Studio with WPF workload
- Node.js and npm
- .NET 6 or later
Create a new WPF Application project in Visual Studio.
Install-Package JsBridgeDotnet.WPF
Install-Package CommunityToolkit.MvvmOpen MainWindow.xaml and add the JsBridgeWebView control:
<Window x:Class="YourApp.MainWindow"
xmlns:jsbridge="clr-namespace:JsBridgeDotnet;assembly=JsBridgeDotnet">
<jsbridge:JsBridgeWebView x:Name="jsBridgeWebView" />
</Window>Create a TimerService.cs file with the following code:
using CommunityToolkit.Mvvm.ComponentModel;
using JsBridgeDotnet;
[JsService("Timer")]
public partial class TimerService : ObservableObject
{
[ObservableProperty]
private bool isRunning = false;
public void Start()
{
IsRunning = true;
Task.Delay(4000).ContinueWith(t => IsRunning = false);
}
}Note: The
[JsService("Timer")]attribute exposes this class to JavaScript with the name "Timer". The[ObservableProperty]attribute from MVVM Toolkit automatically creates theIsRunningproperty with change notification.
In MainWindow.xaml.cs, set up the service provider and initialize the bridge:
public partial class MainWindow : Window
{
public static IServiceProvider ServiceProvider;
public MainWindow()
{
InitializeComponent();
Loaded += async (s, e) => await InitializeAsync();
}
private async Task InitializeAsync()
{
try
{
// Configure the local React app location
await jsBridgeWebView.ConfigureLocalPage("ReactApp", "dist");
// Register your services
var services = new ServiceCollection();
services.AddSingleton<TimerService>();
ServiceProvider = services.BuildServiceProvider();
// Initialize the bridge
await jsBridgeWebView.InitializeAsync(services, ServiceProvider);
}
catch (Exception ex)
{
MessageBox.Show(
$"Initialization error: {ex.Message}",
"Error",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
}
}From your WPF project directory, create a new React app:
npm create vite@latest ReactApp -- --template reactWhen prompted, select React and JavaScript.
cd ReactApp
npm install @kikipoulet/react-dotnetbridgeReplace the contents of src/App.jsx with:
import { useState, useEffect } from 'react';
import { DotnetBridge, useObservableProperty } from '@kikipoulet/react-dotnetbridge';
function App() {
const [timerService, setTimerService] = useState(null);
const [running, setRunning] = useObservableProperty(timerService, 'IsRunning');
useEffect(() => {
const initService = async () => {
const service = await DotnetBridge.getService('Timer');
setTimerService(service);
};
initService();
}, []);
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h2 style={{ color: running ? '#4CAF50' : '#666' }}>
{running ? '⏱️ Timer Running' : '⏹️ Timer Ready'}
</h2>
<button onClick={() => timerService?.Start()} disabled={running} >
Start Timer
</button>
</div>
);
}
export default App;How it works:
DotnetBridge.getService('Timer')retrieves the .NET service we registereduseObservablePropertyautomatically subscribes to property changes and keeps the UI in sync
npm run buildThis creates a dist folder with your compiled React app.
Ensure the path in your MainWindow.xaml.cs matches your React build output:
await jsBridgeWebView.ConfigureLocalPage("ReactApp", "dist");The first argument is the folder name relative to your project root. The second is the subfolder containing the built files (typically dist for Vite).
Add this to your .csproj file to ensure the React app is copied to the output directory:
<ItemGroup>
<Content Include="ReactApp\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>You should see:
- A React UI embedded in your WPF window
- A button that starts a 4-second timer
- The status text updating in real-time when the timer starts and stops
Synchronize simple types (boolean, string, number) between React and C#.
React:
const [isRunning, setIsRunning] = useObservableProperty(timerService, 'IsRunning');
const [input, setInput] = useObservableProperty(timerService, 'Input');
// Display
<p>Status: {isRunning ? 'Running' : 'Stopped'}</p>
<input value={input} onChange={(e) => setInput(e.target.value)} />C# Service:
public class TimerService : ObservableObject
{
[ObservableProperty] private bool isRunning;
[ObservableProperty] private string input;
}Synchronize complex objects with automatic nested property updates.
React:
const [article, setArticle] = useObservableProperty(service, 'Article');
// Nested properties update automatically
<p>{article.name}</p>
<p>${article.price.toFixed(2)}</p>
<img src={article.imageUrl} />C# Service:
public class ArticleInfoService : ObservableObject
{
[ObservableProperty] private Article article
}
public partial class Article : ObservableObject
{
[ObservableProperty]private string name = string.Empty;
[ObservableProperty] private double price = 0;
[ObservableProperty] private string imageUrl = string.Empty;
}Real-time synchronization of collections between .NET and React.
React:
const todos = useObservableCollection(todoService, 'Todos');
// Automatically re-renders when collection changes
{todos.map(todo => (
<li key={todo.id}>
{todo.text}
<button onClick={() => todoService.Delete(todo.id)}>Delete</button>
</li>
))}C# Service:
public class TodoListService
{
public ObservableCollection<Todo> Todos { get; } = new();
public void Delete(string id) => ...
}
public class Todo
{
public int Id { get; set; }
public string Text { get; set; }
}Call C# methods directly from React.
React:
await timerService.Start();C# Service:
public class TimerService
{
public void Start() { /* ... */ }
}Single shared instance across the entire application.
React:
const [timerService, setTimerService] = useState(null);
useEffect(() => {
const initService = async () => {
const service = await DotnetBridge.getService('Timer');
setTimerService(service);
};
initService();
}, []);Dynamic service instances with constructor injection.
React:
const service = await DotnetBridge.getService('ArticleInfo', {
constructorParameters: [articleId]
});
C# Registration:
public class ArticleInfoService
{
public ArticleInfoService(int articleId)
{
...
}
}Automatic getter and setter methods for properties.
React:
const count = await service.GetCount();
await service.SetCount(42);C# Service:
public class CounterService : ObservableObject
{
[ObservableProperty] private int count;
}Subscribe to C# events from React.
React:
useEffect(() => {
const listenerId = service.OnTimerCompleted.subscribe((result) => {
console.log('Timer finished!', result);
});
}, []);C# Service:
public class TimerService
{
public event EventHandler<string> OnTimerCompleted;
}| Feature | React Hook/Method | C# Pattern |
|---|---|---|
| Property Sync | useObservableProperty(service, 'Name') |
ObservableProperty with SetProperty() |
| Collection Sync | useObservableCollection(service, 'Items') |
ObservableCollection<T> |
| Method Call | await service.MethodName() |
Public method in service |
| Singleton | DotnetBridge.getService('Name') |
services.AddSingleton<T>() |
| Transient | getService('Name', { constructorParameters: [] }) |
services.AddTransient<T>() |
| Event | service.OnEventName.subscribe(callback) |
C# event EventHandler<T> |
| Nested Objects | property.subProperty |
Observable object properties |
| Auto Getter | await service.GetPropertyName() |
Property with getter |
| Auto Setter | await service.SetPropertyName(value) |
Property with setter |
Changes in C# automatically trigger React re-renders. No manual event handling or polling required.