Part 3 of the React for C#/.NET Developers series. Part 1 covered fundamentals. Part 2 covered useEffect. This one covers something nobody warns you about until it's almost too late.
I was running a React training for a group of senior .NET developers on a .NET 10 project last month.
Smart developers. Years of experience. They'd sailed through JSX, components, useState, and useEffect without much trouble.
Then someone asked a question that stopped the entire class cold.
"Sir — if React runs in the browser, doesn't that mean our data processing happens on the client side? Isn't that a security risk?"
Silence.
Then everyone slowly turned to look at me. Because they all realized at the same moment that yes — that's exactly what was happening. And we were two days away from demo.
That question led to one of the most frustrating — and ultimately most valuable — implementation challenges I've seen in a training class. And it completely changed how I teach React security to C# developers.
Here's the full story, and the solution we built.
The problem nobody mentions in React tutorials
Most React tutorials show you this flow:
// The tutorial approach
useEffect(() => {
fetch('/api/transactions') // calls your API
.then(r => r.json()) // gets data
.then(data => setTransactions(data)); // stores in state
}, []);
Clean. Simple. Works perfectly. Ship it.
But here's what that actually means from a security perspective:
| What you think happens | What actually happens |
|---|---|
| Data is processed securely | Data is fetched and processed in the browser |
| Server controls data access | JavaScript in the browser controls data access |
| Business logic is protected | Anyone can inspect network requests and responses |
| Sensitive calculations are safe | All calculations happen client-side — visible to anyone |
Coming from C#/.NET where your controllers, services, and business logic live safely on the server — this is a genuinely uncomfortable realization.
And for our .NET 10 project which dealt with financial transaction data, it wasn't just uncomfortable. It was unacceptable.
First — let's cover Context properly
Before I get to the security solution, I need to cover React Context — because it's central to how we solved the problem.
The prop drilling problem
Imagine you have a JWT token after login. You need it in your NavBar, your TransactionList, your AccountDetails, and your Dashboard components. Without Context you'd pass it like this:
// Without Context — prop drilling nightmare
<App token={token}>
<Dashboard token={token}>
<TransactionList token={token}>
<TransactionRow token={token} />
</TransactionList>
</Dashboard>
</App>
// Every component needs token even if it doesn't use it
// Just passing it down to the next level — messy
Sound familiar? In C# terms this is like passing a dependency through every constructor in your call chain instead of using DI.
Context fixes this exactly the way DI fixes it in C# — register once, inject anywhere.
Context is React's dependency injection
| C# Dependency Injection | React Context |
|---|---|
services.AddScoped<IAuthService>() |
createContext(null) |
services.AddScoped<AuthService>() |
AuthProvider component |
constructor(IAuthService auth) |
useContext(AuthContext) |
Program.cs registration |
<AuthProvider> wrapping App |
// 1. Create the context
import { createContext, useContext, useState } from 'react';
const AuthContext = createContext(null);
// 2. Create the provider — like your DI registration
export function AuthProvider({ children }) {
const [token, setToken] = useState(
localStorage.getItem('jwt_token')
);
const login = (newToken) => {
localStorage.setItem('jwt_token', newToken);
setToken(newToken);
};
const logout = () => {
localStorage.removeItem('jwt_token');
setToken(null);
};
return (
<AuthContext.Provider value={{ token, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// 3. Custom hook — like a typed service interface
export function useAuth() {
return useContext(AuthContext);
}
// 4. Wrap your app once — like Program.cs
// main.jsx
root.render(
<AuthProvider>
<App />
</AuthProvider>
);
// 5. Consume anywhere — no prop drilling
function NavBar() {
const { token, logout } = useAuth();
return (
<nav>
<span>{token ? 'Logged in' : 'Guest'}</span>
<button onClick={logout}>Sign Out</button>
</nav>
);
}
💡 Key insight: Context is React's dependency injection. The
AuthProvideris your service registration.useAuth()is your constructor injection. Once you see it that way, it stops being confusing.
Back to the security problem — and how we actually solved it
The standard approach — React fetching data directly from an API — means sensitive data processing happens in the browser. For a banking dashboard dealing with transaction data, that wasn't acceptable.
My student's question led us to a hybrid architecture that took us a frustrating day and a half to implement — but when it finally worked, the relief in the room was palpable.
The solution: Razor Pages handles all sensitive data processing server-side. React Islands consume that pre-processed data through HTML data attributes. No fetch() needed. No client-side data processing.
The architecture we built
| Layer | Technology | Responsibility |
|---|---|---|
| Authentication | ASP.NET Core [Authorize]
|
JWT validation — server side |
| Data processing | Razor Pages PageModel
|
Query DB, shape data, serialize |
| Data delivery | HTML data-* attributes |
Pre-processed JSON in the page |
| UI rendering | React Islands | Read data attributes, render UI |
| No client API calls | None needed | Data already in the page |
The Razor Pages side — DashboardModel.cs
The [Authorize] attribute handles authentication before the page even loads — no JWT token validation happening in JavaScript:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using DashboardProject.Data;
using System.Text.Json;
namespace DashboardProject.Pages;
[Authorize] // ← server-side auth — React never sees unauthenticated data
public class DashboardModel : PageModel
{
private readonly AppDbContext _db;
// DI injection — same pattern you know from C#
public DashboardModel(AppDbContext db) => _db = db;
// JSON strings written into data-* attributes in the .cshtml
// React reads them back — no fetch() needed
public string TransactionsJson { get; private set; } = "[]";
public string SummaryJson { get; private set; } = "[]";
public void OnGet()
{
// Query once — reuse for both serializations
var rows = _db.Transactions.ToList();
// Shape matches what React expects
var txData = rows.Select(t => new
{
t.Id,
accountNumber = t.AccountNumber,
amount = double.TryParse(t.Amount, out var a) ? a : 0d,
type = t.Type,
date = t.Date,
});
// Summary: group by type, sum amounts
var summary = rows
.GroupBy(t => t.Type)
.Select(g => new
{
type = g.Key,
total = g.Sum(t => double.TryParse(t.Amount, out var a) ? a : 0d),
count = g.Count(),
});
// camelCase so JS keys match: t.type, t.amount, s.total
var opts = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
TransactionsJson = JsonSerializer.Serialize(txData, opts);
SummaryJson = JsonSerializer.Serialize(summary, opts);
}
}
The Razor Page — Dashboard.cshtml
The page writes the server-processed JSON directly into HTML data attributes. React never makes an API call — it reads from the DOM:
@page
@model DashboardModel
<div
id="transactions-island"
data-transactions="@Model.TransactionsJson"
data-summary="@Model.SummaryJson">
</div>
<script type="module" src="~/js/dashboard.jsx"></script>
The React Island — reads from data attributes
No useEffect, no fetch(), no API call:
import { useState, useMemo } from 'react';
import { createRoot } from 'react-dom/client';
function TransactionsDashboard() {
const el = document.getElementById('transactions-island');
// Read server-processed data from data attributes
// Data was already validated and shaped by C# on the server
const [transactions] = useState(() =>
JSON.parse(el.dataset.transactions)
);
const [summary] = useState(() =>
JSON.parse(el.dataset.summary)
);
const [filter, setFilter] = useState('All');
// useMemo from Article 2 — efficient filtering
const displayed = useMemo(() =>
filter === 'All'
? transactions
: transactions.filter(t => t.type === filter),
[transactions, filter]
);
return (
<div>
{/* Summary cards from server-computed data */}
{summary.map(s => (
<div key={s.type}>
<h3>{s.type}</h3>
<p>Total: ₱{s.total.toLocaleString()}</p>
<p>Count: {s.count}</p>
</div>
))}
{/* Filter buttons */}
<button onClick={() => setFilter('All')}>All</button>
<button onClick={() => setFilter('Deposit')}>Deposits</button>
<button onClick={() => setFilter('Withdraw')}>Withdrawals</button>
{/* Transaction list */}
{displayed.map(t => (
<div key={t.id}>
<span>{t.accountNumber}</span>
<span>{t.type}</span>
<span>₱{t.amount.toLocaleString()}</span>
</div>
))}
</div>
);
}
// Mount the React Island
const el = document.getElementById('transactions-island');
if (el) createRoot(el).render(<TransactionsDashboard />);
Why this approach is more secure
| Standard React SPA | Razor Pages + React Islands |
|---|---|
| Auth checked in JavaScript | Auth checked by [Authorize] on server |
| Data fetched client-side | Data processed server-side before page loads |
| Business logic in browser | Business logic in C# — never leaves server |
| API endpoints exposed | No additional API endpoints needed |
| JWT stored in localStorage | Session managed by ASP.NET Core |
⚠️ Security note: This approach doesn't mean React SPAs are insecure — they can be built securely too. But for applications dealing with sensitive financial or personal data, keeping processing server-side adds a meaningful layer of protection that C#/.NET developers will immediately recognize and trust.
When to use which approach
This isn't an either/or decision. Here's how I guide my students:
| Use React SPA when | Use Razor Pages + React Islands when |
|---|---|
| Public-facing UI with no sensitive data | Financial or personal data processing |
| Real-time updates needed (WebSockets) | Server-side auth is non-negotiable |
| Complex client-side interactions | SEO matters for your pages |
| Separate frontend team | Existing Razor Pages app adding React gradually |
| Mobile app consuming the same API | .NET 10 project with mixed page types |
In our .NET 10 project we ended up using both — React Islands on sensitive dashboard pages, pure React SPA for the less sensitive reporting pages. The architecture supports both patterns cleanly.
The moment it finally worked
After a day and a half of frustration — fighting with data attribute escaping, JSON serialization options, camelCase vs PascalCase mismatches, and React hydration timing — we got it working.
The dashboard loaded. The data appeared. The filters worked. And most importantly — opening the browser network tab showed exactly zero API calls to our sensitive transaction endpoints.
The room went quiet for a second.
Then someone said: "That's actually really clean."
And they were right. Once you get past the implementation complexity the pattern is elegant — C# does what C# is good at (server-side data processing, authentication, business logic) and React does what React is good at (rendering, interactivity, state management).
Twenty years of .NET experience finally felt like an advantage in a React project rather than baggage to overcome.
What's next in this series
Article 4 will cover AG Grid for C#/.NET developers — column definitions, filtering, sorting, and the pivot table feature that makes your manager's eyes light up. All mapped to concepts you already know from working with data grids in WinForms and WPF.
If you missed the earlier articles:
- Part 1 — React fundamentals mapped to C# (JSX, components, props, state)
- Part 2 — useEffect finally explained for C# developers (Form_Load, INotifyPropertyChanged, IDisposable)
Want the complete 8-hour training?
Everything in this series plus JWT authentication, AG Grid with pivot tables,
Recharts dashboards, and the complete SecureBank build — packaged as an 8-hour
self-paced guide specifically for C#/.NET developers.
React for C#/.NET Developers — Complete Training Guide
Has your team hit the same security concern with React? How did you solve it? Drop it in the comments — I'd genuinely love to know if others took a different approach. 👇
Top comments (0)