Mutation
A mutation in Alette Signal is a request blueprint provided by the "core" plugin and is preconfigured for sending POST
, PUT
, PATCH
and DELETE
HTTP requests.
Preconfigured mutation behaviour
- Uses the
POST
HTTP method by default to send a request to the server. - Is not executed on mount by default.
- Retries the request once if the thrown error contains
401
or419
HTTP status code. - Throws a
RequestFailedError
if the response returned from the server does not have a2xx
HTTP status. - Throws a
HttpMethodValidationError
if a mutation request was attempted with theGET
HTTP method provided.
WARNING
Mutations are designed for modifying data on your backend. If you want to get server data without modifying it on the backend, use query.
Using mutation blueprint
To use the mutation request blueprint, extract it from the Alette Signal "core" plugin:
// ./src/api/base.ts
import { client, activatePlugins, coreApiPlugin } from "@alette/signal";
const core = coreApiPlugin();
export const api = client(
/* ... */
activatePlugins(core.plugin),
);
export const { mutation } = core.use();
Now you can add middleware and execute requests:
// ./src/api/email.ts
import { input, output, path, body } from '@alette/signal';
import { mutation } from "./base";
import * as z from 'zod';
export const scheduleEmail = mutation(
input(
z.object({
id: z.string(),
receiver: z.string().default("alette-signal@mail.com"),
topic: z.string().default("Hello!"),
message: z.string().default("How are things?")
})
),
output(z.string()),
path('/email/schedule'),
body(({ args }) => args)
);
export const cancelScheduledEmail = mutation(
input(z.string()),
output(z.boolean()),
path('/email/cancel'),
body(({ args: scheduledEmailId }) => ({
id: scheduledEmailId
}))
);
// Later...
const scheduledEmailId = await scheduleEmail.execute()
// or
await cancelScheduledEmail.execute({ args: scheduledEmailId })
Using mutation with UI frameworks
To use the mutation request blueprint with UI frameworks, refer to the Alette Signal framework integration guides:
Sending body
To send a request body, use the body()
middleware:
mutation(
body({ hey: 'Alette Signal' })
)
To create a body from request data, pass a callback to the body()
middleware:
const greet = mutation(
input(z.string()),
body(({ args: name }) => ({ hey: name })),
// or
body(async ({ args: name }) => ({ hey: name }))
)
await greet.execute({ args: 'Alette Signal' })
Accepted body types
The body()
middleware accepts 7 body types:
- Objects convertable to
JSON
. - Plain text.
FormData
.URLSearchParams
.Blob
.ArrayBuffer
.Uint8Array
.
Body headers
The body()
middleware sets request headers automatically based on the passed body type. There are 7 variations of automatically injected request headers:
- For objects convertable to
JSON
:
{
"Content-Type": "application/json;charset=UTF-8";
}
- For plain text:
{
"Content-Type": "text/plain;charset=UTF-8";
}
- For
FormData
:
{
// Nothing
}
DANGER
Setting { "Content-Type": "multipart/form-data" }
headers for FormData
will prevent the browser from appending the required boundary string, which prevents the server from parsing the form data correctly.
- For
URLSearchParams
:
{
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8";
}
- For
ArrayBuffer
,Blob
orUint8Array
:
{
"Content-Type": "application/octet-stream";
}
Changing body headers
To change automatically set body()
headers, place the headers()
middleware after body()
middleware:
mutation(
// Sets { "Content-Type": "application/json;charset=UTF-8" }
// headers automatically.
body({ convertMeToJson: true }),
// Overrides previously set headers
headers({
"Content-Type": "text/plain;charset=UTF-8",
})
)
WARNING
User provided headers and body headers are merged if there is no collision:
mutation(
// Sets { "Content-Type": "application/json;charset=UTF-8" }
// headers automatically.
body({ convertMeToJson: true }),
// Does not override previously set headers,
// because it does not contain the "Content-Type" header.
headers({
"other-header": "hello",
})
)
// Final headers
{
"Content-Type": "application/json;charset=UTF-8",
"other-header": "hello"
}
Changing HTTP method
To change request HTTP method, use the posts()
, puts()
, patches()
, deletes()
or method()
middleware:
mutation(
posts(), // method('POST') under the hood
puts(), // method('PUT') under the hood
patches(), // method('PATCH') under the hood
deletes(), // method('DELETE') under the hood
method('POST'),
)
INFO
There is also gets()
middleware available for custom requests.
Progress tracking
To track request body upload progress, use the tapUploadProgress()
middleware:
mutation(
tapUploadProgress(({ progress, uploaded, remaining }) => {
console.log(`Completed by ${progress}%`);
console.log(`Uploaded bytes "${uploaded}"`);
console.log(`Remaining bytes "${remaining}"`);
}),
)
To track response download progress, use the tapDownloadProgress()
middleware:
mutation(
tapDownloadProgress(({ progress, downloaded, remaining }) => {
console.log(`Completed by ${progress}%`);
console.log(`Downloaded bytes "${downloaded}"`);
console.log(`Remaining bytes "${remaining}"`);
}),
)
Mutation mounted execution
To enable mutation execution on mount, use the runOnMount()
middleware:
cancelScheduledEmail.with(
runOnMount()
);
TIP
One example of a mounted mutation is email confirmation. When users visit the "email confirmed" screen from their mail client, a request confirming their email validity is sent:
export const autoMarkEmailAsConfirmed = mutation(
input(as<number>()),
runOnMount()
)
// Somewhere in the "EmailConfirmed" React component:
const userId = /* ... */
useApi(
autoMarkEmailAsConfirmed.using(() => ({ args: userId })),
[userId]
)
TIP
runOnMount()
also works with custom requests.
File upload
To upload files to your backend, use FormData
together with the body()
middleware:
export const uploadFiles = mutation(
input(z.instanceOf(FormData)),
output(z.boolean()),
path('/files/upload'),
body(({ args: files }) => files)
);
const collectedFiles = new FormData();
const myFile1 = new Blob();
const myFile2 = new Blob();
collectedFiles.append('file', myFile1);
collectedFiles.append('file', myFile2);
// Later...
await uploadFiles.execute({ args: collectedFiles })
Tracking file upload progress
To track file upload progress, use the tapUploadProgress()
middleware:
uploadFiles.with(
tapUploadProgress(({ progress, uploaded, remaining }) => {
console.log(`Completed by ${progress}%`);
console.log(`Uploaded bytes "${uploaded}"`);
console.log(`Remaining bytes "${remaining}"`);
}),
);
Mutation cancellation
To cancel an in-flight mutation request, use cancel()
:
const { execute, cancel } = scheduleEmail.mount()
execute()
// Later...
cancel()
WARNING
Request cancellation does not throw errors.
DANGER
Mutation cancel()
has 2 possible outcomes:
- The mutation is cancelled before it reaches the server and modifies your backend data. If this is the case, nothing should be done.
- The cancellation fails to catch and cancel the mutation before it reaches your server. In this case, the mutation has already succeeded, but Alette Signal will treat it as cancelled. This is called a "false positive mutation cancellation".
Fixing false positive cancellations
To fix a false positive mutation cancellation, use the tapCancel()
middleware to send a request back to the server that reverts the mutation:
scheduleEmail
.with(
tapCancel(async ({ args: { id: emailId } }) => {
await cancelScheduledEmail.execute({ args: emailId });
console.log("Mutation was safely cancelled.")
})
)
.using(() => ({ args: { topic } }))
// Later...
cancel()
INFO
Reverting a mutation after cancellation is called "compensation".
DANGER
Always revert mutations manually after cancellation - cancel()
by itself can result in a "false positive" mutation cancellation.
Mutation abortion
To abort an in-flight mutation request, call the .abort()
method on the AbortController passed to the abortedBy()
middleware:
const abortController = new AbortController();
// ...
scheduleEmail
.with(abortedBy(abortController))
.using(() => ({ args: { topic } }))
// Later...
abortController.abort()
DANGER
Request abortion throws a RequestAbortedError
.
DANGER
Request abortion can result in false positive mutation cancellation. To avoid this, revert the mutation using the tapAbort()
middleware:
scheduleEmail
.with(
abortedBy(abortController),
tapAbort(async ({ args: { id: emailId } }) => {
await cancelScheduledEmail.execute({ args: emailId });
console.log("Mutation was safely aborted.")
})
)
.using(() => ({ args: { topic } }))
Mutation limitations
- Cannot use the
GET
HTTP method to execute requests. - Cannot implement custom request execution logic using the
factory()
middleware. - Cannot add new thrown error types using the
throws()
middleware. - Expects a response in
JSON
format back from the server.