I have a problem that I have had with blazor server side for a long time. I have an edit form for an EF model but I cannot figure out how to handle cancelation. This is my code:
@inject NavigationManager nav
<h3>Edit @_tmpCustomer.FullName</h3>
<EditForm Model=@_tmpCustomer>
<DataAnnotationsValidator/>
<ValidationSummary/>
<label>First Name</label>
<InputText @bind-Value=_tmpCustomer.FName/>
<br/>
<label>Last Name</label>
<InputText @bind-Value=_tmpCustomer.LName/>
<br/>
<label>Phone Number</label>
<InputText @bind-Value=_tmpCustomer.PhoneNumber/>
<br/>
<button @onclick=UpdateCustomer>Save</button>
<button @onclick=@(() => NavTo("/customers"))> Cancel</button>
</EditForm>
@code {
[Parameter]
public int customerId { get; set; }
private Customer _customer { get; set; }
private Customer _tmpCustomer { get; set; }
protected override async Task OnInitializedAsync()
{
await LoadCustomer(customerId);
}
private async Task LoadCustomer(int customerId)
{
_customer = await _customerRepo.GetCustomerFromId(customerId);
_tmpCustomer = (Customer)_customer.Clone();
}
private async Task UpdateCustomer()
{
_customer = _tmpCustomer;
await _customerRepo.Update(_customer);
await NavTo("/customers");
}
private async Task NavTo(string uri)
{
nav.NavigateTo(uri);
}
}
public class Customer
{
...
public virtual object Clone()
{
return this.MemberwiseClone
}
}
public class CustomerRepo
{
private ApplicationDbContext _context;
public CustomerRepo(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<Customer>> GetAllCustomers()
{
return _context.Customers.ToList();
}
public async Task<Customer> GetCustomerFromId(int customerId)
{
return _context.Customers.FirstOrDefault(c => c.Id == customerId);
}
public async Task Create(Customer customer)
{
_context.Add(customer);
await _context.SaveChangesAsync();
}
public async Task Update(Customer customer)
{
_context.Update(customer);
await _context.SaveChangesAsync();
}
}
The problem is that I cannot have 2 instances of the same EF model tracked at the same time, I could detach it but I don't think that's the clean or right way to do this.
What would be the correct way to cancel an edit form in blazor server?
Thanks :)
CodePudding user response:
The problem is that the same DbContext is used for the entire user session. This is explained in the Database Access section of the Blazor Server and EF Core docs. You'll have to manage the DbContext's lifetime yourself.
The documentation example shows registering a DbContextFactory with :
builder.Services.AddDbContextFactory<ContactContext>(opt =>
opt.UseSqlite($"Data Source={nameof(ContactContext.ContactsDb)}.db"))
Which is similar to what you'd use with AddDbContext.
Then the page injects the DbContextFactory instead of the actual DbContext and creates a new DbContext instance in OnInitialized
@implements IDisposable
@inject IDbContextFactory<ContactContext> DbFactory
.....
protected override async Task OnInitializedAsync()
{
Busy = true;
try
{
Context = DbFactory.CreateDbContext();
if (Context is not null && Context.Contacts is not null)
{
var contact = await Context.Contacts.SingleOrDefaultAsync(c => c.Id == ContactId);
if (contact is not null)
{
Contact = contact;
}
}
}
finally
{
Busy = false;
}
await base.OnInitializedAsync();
}
The page has to implement IDisposable
so it can dispoce the Context
instance when you navigate away from the page:
public void Dispose()
{
Context?.Dispose();
}
Explanation
In web applications the Dependency Injection scope is the controller action, which means scoped services are created when a new action is executed and disposed when it completes. This works nicely with DbContext's Unit-of-Work semantics, as changes are cached only for a single action. "Cancelling" means simply not calling SaveChanges
.
Blazor Server behaves like a desktop application though, and the scope is the entire user circuit (think of it as a user session). You'll have to create a new DbContext for each unit of work.
One way to do this is to just create a new DbContext explicitly, ie new ApplicationDbContext()
. This works but requires hard-coding the configuration.
Another option is to inject a DbContextFactory instead of the ApplicationDbContext
into your CustomerRepo
or page, and create the DbContext instance as needed. Note that CustomerRepo
itself is now alive for the entire user session, so you can't depend on it working as just a wrapper over DbContext.
CodePudding user response:
What would be the correct way to cancel an edit form in blazor server?
I do the following:
- All data classes are immutable records. You can't change them.
- I create an edit class for the data class and read in the data from the record.
- This class does validation and state management. For instance you can create a record from the edit class at any time and do equality check against the original to see if your record state is dirty.
- I create a new record from the edit class to submit to EF to update the database.
- I use the DBContext factory and apply "Unit of Work" principles.
Canceling the edit is then simple, you don't save!
First change over to using the DBContextFactory. The MSSQL one looks like this:
builder.Services.AddDbContextFactory<ApplicationDbContext>(options => options.UseSqlServer("ConnectionString"), ServiceLifetime.Singleton);
And update your Customer Repo to use this.
public class CustomerRepo
{
private IDbContextFactory<ApplicationDbContext> _dbContextFactory;
public CustomerRepo(IDbContextFactory<ApplicationDbContext> factory)
=> _dbContextFactory = factory;
And the Create
method now becomes:
public async ValueTask Create(Customer customer)
{
using var context = _dbContextFactory.CreateDbContext();
_context.Add(customer);
// can check you get 1 back
await _context.SaveChangesAsync();
}
Change Customer to a record
public record Customer
{
public int Id { get; init; }
public string? Name { get; init; }
}
Create an edit class for Customer
public class DeoCustomer
{
private Customer _customer;
public int Id { get; set; }
public string? Name { get; set; }
public DeoCustomer(Customer customer)
{
this.Id = customer.Id;
this.Name = customer.Name;
_customer = customer;
}
public Customer Record =>
new Customer
{
Id = this.Id,
Name = this.Name
};
public bool IsDirty =>
_customer != this.Record;
}
And then your edit form can look something like this:
// EditForm using editRecord
@code {
private DeoCustomer? editRecord = default!;
private async Task LoadCustomer(int customerId)
{
var customer = await _customerRepo.GetCustomerFromId(customerId);
editRecord = new DeoCustomer(customer);
}
private async Task UpdateCustomer()
{
if (editRecord.IsDirty)
{
var customer = editRecord.Record;
await _customerRepo.Update(customer);
}
await NavTo("/customers");
}
}
I haven't actually run the code so there may be some typos!