1 - How to: Manage state with the Dapr JavaScript SDK
Learn how to save, retrieve, and delete state, use ETags, and execute bulk operations and transactions
Use DaprClient.state to manage key/value application state without depending on a particular database client. This guide uses Redis and covers basic operations, optimistic concurrency, bulk operations, TTL, and transactions.
Prerequisites
In your application directory, initialize an npm project if needed and install the SDK:
npm init -y
npm install @dapr/dapr@3.19.0
The examples use JavaScript ES modules in an .mjs file. The same APIs are available in TypeScript.
Create a components directory and save this component as components\statestore.yaml:
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: statestore
spec:
type: state.redis
version: v1
metadata:
- name: redisHost
value: "localhost:6379"
- name: redisPassword
value: ""
Adjust the connection settings for your Redis instance. See the Redis component reference for authentication and production configuration.
The component’s metadata.name, statestore, is the store name used in client calls. It is not the component type or the file name. Keep the same Dapr application ID between runs to read previously saved state; Dapr prefixes keys with the application ID by default.
Save, retrieve, and delete state
Save the following as app.mjs:
import { CommunicationProtocolEnum, DaprClient, JsonSerializer } from "@dapr/dapr";
const client = new DaprClient({
daprHost: "127.0.0.1",
daprPort: "3500",
communicationProtocol: CommunicationProtocolEnum.HTTP,
serializer: new JsonSerializer(),
});
const storeName = "statestore";
try {
const saved = await client.state.save(storeName, [
{ key: "widget-a", value: { size: "small", color: "blue" } },
{ key: "widget-b", value: { size: "large", color: "white" } },
]);
if (saved.error) {
throw saved.error;
}
const widget = await client.state.get(storeName, "widget-a");
console.log("Loaded widget:", widget);
const deleted = await client.state.delete(storeName, "widget-b");
if (deleted.error) {
throw deleted.error;
}
} catch (error) {
console.error("State operation failed:", error);
process.exitCode = 1;
} finally {
await client.stop();
}
Start the application with a Dapr sidecar:
dapr run --app-id state-example --dapr-http-port 3500 --dapr-grpc-port 50001 --resources-path .\components -- node app.mjs
To use gRPC, change communicationProtocol to CommunicationProtocolEnum.GRPC and daprPort to "50001". The explicit JsonSerializer makes value serialization consistent across the examples, including object-valued transactions and query results. It is optional, but opting in can change how existing values are interpreted. Read the serialization guidance before changing an existing application’s configuration.
The remaining examples reuse client and storeName. Place them inside the try block before the client is stopped. They assume the initial save has completed.
Read multiple entries
save already accepts an array for bulk writes. Use getBulk to read multiple keys:
const items = await client.state.getBulk(
storeName,
["widget-a", "widget-b"],
{ parallelism: 4 },
);
for (const item of items) {
if (item.error) {
throw new Error(`Unable to read ${item.key}: ${item.error}`);
}
console.log(item.key, item.data, item.etag);
}
Each result is an item with key, data, and an optional etag, not just the saved value. Match results by key rather than relying on array order. In this example, widget-b has already been deleted; missing values differ between protocols. See response shapes for details.
get returns only the value, not the response ETag. To read a value and its ETag, use getBulk with one key. Pass the returned ETag to a subsequent save or delete to detect concurrent changes.
import { StateConcurrencyEnum, StateConsistencyEnum } from "@dapr/dapr";
Keep this import at module scope. Inside the try block:
const itemsWithEtags = await client.state.getBulk(storeName, ["widget-a"]);
const current = itemsWithEtags.find((item) => item.key === "widget-a");
if (!current || current.error || !current.etag) {
throw new Error("Could not read widget-a with an ETag");
}
const updated = await client.state.save(storeName, [
{
key: "widget-a",
value: { ...current.data, color: "green" },
etag: current.etag,
options: {
concurrency: StateConcurrencyEnum.CONCURRENCY_FIRST_WRITE,
consistency: StateConsistencyEnum.CONSISTENCY_STRONG,
},
},
]);
if (updated.error) {
throw updated.error;
}
The state store must support ETags. With first-write concurrency, an ETag mismatch indicates that another writer changed the value. Handle the conflict according to your application’s rules, for example by reading the latest value and recomputing the update. Do not blindly retry a stale ETag.
For a conditional delete, read a fresh ETag after any successful update:
const latestItems = await client.state.getBulk(storeName, ["widget-a"]);
const latest = latestItems.find((item) => item.key === "widget-a");
if (!latest || latest.error || !latest.etag) {
throw new Error("Could not read widget-a with an ETag");
}
const removed = await client.state.delete(storeName, "widget-a", {
etag: latest.etag,
concurrency: StateConcurrencyEnum.CONCURRENCY_FIRST_WRITE,
consistency: StateConsistencyEnum.CONSISTENCY_STRONG,
});
if (removed.error) {
throw removed.error;
}
HTTP write failures can be returned in result.error, while gRPC failures reject the promise. The examples check the result and also have an outer error handler so neither path is ignored.
Delete multiple entries
deleteBulk accepts key-only items, with optional ETags, per-item metadata, and state options. It does not require or serialize values:
const bulkDeleted = await client.state.deleteBulk(storeName, [
{ key: "widget-a" },
{ key: "widget-b" },
]);
if (bulkDeleted.error) {
throw bulkDeleted.error;
}
An empty array succeeds without contacting the sidecar. Over HTTP, the SDK issues individual deletes and waits for all of them; over gRPC, it sends one bulk-delete RPC. Neither is atomic, and a failure can leave some entries deleted. Use a transaction when all-or-nothing behavior is required.
Execute a state transaction
Use transaction to perform multiple upsert and delete operations atomically in a store that supports transactions:
await client.state.transaction(storeName, [
{
operation: "upsert",
request: {
key: "widget-a",
value: { size: "medium", color: "green" },
},
},
{
operation: "delete",
request: { key: "widget-b" },
},
]);
This example uses the JsonSerializer configured above. Without an explicit serializer, the gRPC transaction implementation expects an already encoded string or bytes rather than serializing object values as save does.
The optional third argument is transaction-wide metadata:
await client.state.transaction(
storeName,
[
{
operation: "upsert",
request: { key: "widget-c", value: { size: "small", color: "red" } },
},
],
{ partitionKey: "widgets" },
);
Metadata and transaction constraints depend on the component. For example, a store may require all operations to use the same partition. See the client usage limitations before using per-operation metadata or ETags in a transaction.
Set a time to live
For a store that supports TTL, pass ttlInSeconds as metadata. This saves an entry that expires after 60 seconds:
const expiring = await client.state.save(
storeName,
[{ key: "temporary-widget", value: { color: "blue" } }],
{ metadata: { ttlInSeconds: "60" } },
);
if (expiring.error) {
throw expiring.error;
}
Metadata can also be supplied on individual save items. Shared save metadata overrides matching per-item metadata keys. See state store TTL for component support and expiration semantics.
Next steps
- State client usage: options, serialization, metadata, response shapes, and errors.
- Query state: filtering, sorting, and pagination with a query-capable store.
2 - State client usage
Configuration, options, serialization, response shapes, and protocol-specific behavior of the JavaScript state client
State management is available through DaprClient.state. This page describes the implementation in 3.19.0 and the behavior to account for when using that package or a later compatible release.
Client lifetime and configuration
Reuse a long-lived DaprClient instead of constructing one per operation. Await outstanding requests before calling await client.stop() during shutdown.
HTTP is the default protocol. To use gRPC:
import { CommunicationProtocolEnum, DaprClient } from "@dapr/dapr";
const client = new DaprClient({
daprHost: "127.0.0.1",
daprPort: "50001",
communicationProtocol: CommunicationProtocolEnum.GRPC,
daprApiToken: process.env.DAPR_API_TOKEN,
});
For HTTP, use CommunicationProtocolEnum.HTTP and the sidecar’s HTTP port, normally "3500". Port options are strings.
When explicit host and port options are omitted, the client can read DAPR_HTTP_ENDPOINT or DAPR_GRPC_ENDPOINT for the selected protocol. Otherwise it uses host/port options with defaults of 127.0.0.1 and DAPR_HTTP_PORT or DAPR_GRPC_PORT (normally 3500 and 50001). The API token defaults to DAPR_API_TOKEN.
Choose an operation
Every method takes a state store component name as its first argument.
| Method | Purpose | Successful result |
|---|
save(storeName, items, options?) | Save one or more key/value items. | {} |
get(storeName, key, options?) | Read a single value. | The value, not an item containing an ETag. |
getBulk(storeName, keys, options?) | Read values and ETags for multiple keys. | Array of result items. |
delete(storeName, key, options?) | Delete one key, optionally checking an ETag. | {} |
deleteBulk(storeName, items, options?) | Delete multiple keys without supplying values. | {} |
transaction(storeName, operations?, metadata?) | Atomically execute upserts and deletes in a supporting store. | undefined (Promise<void>). |
query(storeName, query) | Filter, sort, and paginate a supporting store. | { results, token? } |
The SDK does not expose a separate single-item save, a getWithETag method, a boolean-returning conditional write, or a generic get<T> method. Use a one-element array for single saves and bulk reads that need an ETag. The broadly typed read results do not perform runtime schema validation.
State options
Import StateConsistencyEnum and StateConcurrencyEnum from @dapr/dapr rather than passing the HTTP wire-format strings:
| Option | Values | Meaning |
|---|
consistency | CONSISTENCY_UNSPECIFIED, CONSISTENCY_EVENTUAL, CONSISTENCY_STRONG | Select the store default, eventual consistency, or strong consistency. |
concurrency | CONCURRENCY_UNSPECIFIED, CONCURRENCY_FIRST_WRITE, CONCURRENCY_LAST_WRITE | Select the store default, detect conflicting writes with ETags, or use last-write-wins behavior. |
These are members of the corresponding enum. Support depends on the state store. ETags are opaque version strings; do not invent or increment them.
Options have different locations depending on the operation:
| Operation | Where to put options |
|---|
save | Each item’s options: { concurrency, consistency } and optional string etag. The final argument accepts shared metadata only. |
get | Final argument: { consistency, metadata }, with either field optional. |
getBulk | Final argument: { parallelism, metadata }. There is no consistency option. |
delete | Final argument: { concurrency, consistency, etag, metadata }, with fields optional. |
deleteBulk | Each item’s options, etag, and metadata; final argument accepts shared metadata only. |
transaction | Each operation’s request.options; transaction-wide metadata is the third argument. See the limitations below. |
When supplying an item’s options from TypeScript, its IStateOptions type requires both concurrency and consistency. Use the respective UNSPECIFIED enum member when you want the store default for one of them.
Use string key/value metadata supported by the component, such as ttlInSeconds, partitionKey, or contentType. State metadata is not an arbitrary HTTP header collection.
| Operation | HTTP behavior | gRPC behavior |
|---|
save | Sends item metadata in the body and shared metadata as query parameters. | Merges shared metadata into each item, overriding duplicate keys. |
get | Forwards options.metadata. | Does not forward options.metadata in this implementation. |
getBulk | Forwards options.metadata. | Forwards options.metadata. |
delete | Forwards options.metadata. | Does not forward options.metadata in this implementation. |
deleteBulk | Merges shared metadata over item metadata, then forwards it in individual deletes. | Merges shared metadata over item metadata in one bulk-delete RPC. |
transaction | Forwards transaction-wide metadata and each request’s metadata. | Forwards transaction-wide metadata, but not each request’s metadata. |
query | No separate metadata argument. | No separate metadata argument. |
For saves, shared metadata takes precedence over matching item metadata keys. TTL values belong in metadata, not in state options. See state store TTL.
If a component requires per-request metadata to locate an entry, do not assume single-key reads and deletes behave identically across protocols. Use HTTP for those calls, or use getBulk and deleteBulk with metadata where appropriate.
Response shapes
Single read
get returns the deserialized value, or text if the default JSON parse fails. It does not return the HTTP headers or the gRPC response envelope. A missing key returns an empty string in the standard no-data response, not a guaranteed undefined or null.
Do not use a truthiness check to distinguish a missing key from a legitimate stored value such as false, 0, or "". If your application needs an unambiguous presence check, store a document envelope and use a bulk read to examine the item and ETag, taking the component’s capabilities into account.
Bulk read
Items contain key, data, and an optional etag. HTTP returns the runtime’s item objects, which may also include error and metadata. A missing HTTP item’s data can be absent or null; gRPC maps empty data to "".
Without a configured serializer, gRPC bulk reads return only key, data, and etag; per-item error and metadata are not preserved by that mapping. With a serializer configured, gRPC preserves those fields as well. Where available, inspect each item’s error even when the overall request succeeded. Match items by key, not array position.
Set parallelism explicitly if your application needs a particular concurrency limit for bulk reads. When it is omitted, the protocols differ in this implementation: gRPC sends the SDK default of 10, while HTTP omits the field and the runtime default applies.
Writes and deletes
save, delete, and deleteBulk return a StateSaveResponseType: an empty object on success, with an optional error on HTTP failure. They do not return a new ETag. Read it again before the next conditional update.
Query
query returns results and an optional pagination token. Each result has key, data, and optional etag and error. Data decoding depends on protocol and serializer configuration; see query guidance.
Bulk deletion
deleteBulk accepts a readonly array of items shaped like save items without the required value. Existing key/value arrays are also accepted; their values are ignored and the serializer is not invoked.
HTTP sends individual deletes concurrently and waits for all requests to finish. It returns the first error in input order, not necessarily the first request to fail. gRPC sends one DeleteBulkState RPC and rejects on RPC failure. An empty batch returns {} without initializing the sidecar connection.
Bulk deletion is not atomic on either protocol. A reported failure does not mean no keys were deleted, and the response does not provide a complete per-key outcome list. Reconcile partial completion if your application needs to retry safely.
Transactions
Use upsert and delete operation names. The underlying component must support transactions; save and deleteBulk do not provide equivalent atomicity.
There are important differences in this implementation:
- Without an explicit serializer, gRPC transaction upserts pass the supplied value to
Buffer.from rather than applying save’s object serialization. Configure JsonSerializer for object-valued transactions, or supply appropriately encoded data for that protocol. - gRPC does not forward individual
request.metadata fields. Use transaction-wide metadata where the component supports it. - The transaction request type declares
etag as { value: string }, but the gRPC implementation calls toString() on that object rather than reading its value. Do not rely on transaction ETags for portable conditional writes in this release. Use the documented single-item save/delete pattern when atomic multi-key conditions are not required, or verify a later SDK fix before relying on conditional transactions.
Serialization
With no serializer configured, the SDK retains its legacy behavior:
| Operation | HTTP | gRPC |
|---|
| Save | Values are part of a JSON request body. | Objects are JSON-stringified; primitives are converted to strings. |
| Get | Parses JSON when possible, otherwise returns text. | Parses JSON when possible, otherwise returns text. |
| Bulk get | Returns the runtime’s JSON item data. | Parses each item’s bytes as JSON when possible, otherwise returns text. |
| Query | Returns the runtime’s JSON item data. | Returns raw Uint8Array item data. |
For consistent JSON value encoding, explicitly configure the exported JsonSerializer:
import { DaprClient, JsonSerializer } from "@dapr/dapr";
const client = new DaprClient({
serializer: new JsonSerializer(),
});
The serializer is a client-wide setting, not a per-state-call option. For state, it serializes saved values and transaction upsert values, and deserializes values returned by single reads, bulk reads, and queries. It does not serialize keys, ETags, metadata, delete values, or query filter expressions.
TypeScript applications can implement the exported IDaprSerializer interface, with synchronous serialize<T>(value): string and deserialize<T>(data: string): T methods and an optional contentType. A JSON media type (the default, application/json, or a +json media type) must produce valid JSON. A non-JSON serializer’s output is stored as a JSON string inside the Dapr envelope; encode binary formats as text, for example using base64. This does not turn state management into a streaming file API.
The serializer’s contentType controls SDK encoding; it does not automatically add state component contentType metadata. Supply that metadata explicitly when required by the store, such as when storing queryable JSON documents.
All readers and writers must agree on the stored representation. Opting in to JsonSerializer can change existing string behavior, especially gRPC values previously stored as unquoted text. Test old data and cross-protocol readers before migrating. Custom serializers can also change which document fields are available to state queries. Type parameters do not supply runtime schema validation or automatically restore class instances.
Error handling
Await operations and handle both returned write errors and promise rejections:
try {
const result = await client.state.save("statestore", [
{ key: "widget-a", value: { color: "blue" } },
]);
if (result.error) {
throw result.error;
}
} catch (error) {
console.error("Could not save widget-a:", error);
throw error;
}
This snippet assumes an existing client and an async function or ES module.
| Failure path | Behavior |
|---|
HTTP save and delete request failures | Logged by the SDK and returned as { error }. |
HTTP deleteBulk request failures | Individual deletes log failures; the bulk call returns the first error in input order after all deletes finish. |
HTTP get, getBulk, transaction, and query request failures | Reject the promise. |
| gRPC state RPC failures | Reject the promise, preserving the transport error, normally a ConnectError. |
| Serialization or local request construction failures | Can reject before a request is sent, including on write operations. |
HTTP runtime errors normally have a JSON-encoded Error.message with status, error, and error_msg. Transport errors can have other shapes; do not unconditionally parse every error message as JSON. Do not interpret gRPC error codes as HTTP status codes.
An ETag conflict is an error, not a false result. Read or query calls can also succeed at the request level while containing item-level errors. Avoid retrying a whole non-atomic batch without considering partial completion.
Cancellation
The state methods in this release do not accept an AbortSignal, cancellation token, or per-operation deadline. Do not copy the Binary Store client’s signal option into state calls. Racing a promise against a timer does not cancel the underlying state request. Use runtime resiliency policies where appropriate for component timeouts and retries, accounting for the semantics of the operation.
3 - How to: Query state with the Dapr JavaScript SDK
Filter, sort, and paginate state with the JavaScript SDK’s alpha state query API
Use client.state.query to query JSON documents in a state store that supports the state query API. Both HTTP and gRPC are implemented in 3.19.0. This is the alpha state query API, not a general-purpose SQL or native database query interface.
Prerequisites
Follow the state management how-to to install the SDK and run an application with a sidecar. For this guide, configure a query-capable component named state-mongodb, following the MongoDB state store reference. The basic Redis component in the how-to does not by itself configure query support.
Use a JSON-compatible stored document representation. Some components require additional query configuration or metadata; check the component reference.
Use the following client configuration inside an ES module, and wrap application operations in an error boundary with await client.stop() in finally, as shown in the state management how-to:
import { CommunicationProtocolEnum, DaprClient, JsonSerializer } from "@dapr/dapr";
const client = new DaprClient({
daprHost: "127.0.0.1",
daprPort: "3500",
communicationProtocol: CommunicationProtocolEnum.HTTP,
serializer: new JsonSerializer(),
});
For gRPC, change the protocol to CommunicationProtocolEnum.GRPC and the port to "50001". The explicit serializer deserializes query result values on either protocol. Without a serializer, gRPC query results contain raw Uint8Array data rather than parsed documents.
Save queryable documents
Supply contentType metadata when the store requires it to retain JSON documents:
const saved = await client.state.save(
"state-mongodb",
[
{
key: "widget-a",
value: { size: "small", color: "blue", warehouse: { region: "west" } },
},
{
key: "widget-b",
value: { size: "large", color: "blue", warehouse: { region: "east" } },
},
],
{ metadata: { contentType: "application/json" } },
);
if (saved.error) {
throw saved.error;
}
Filter and sort
The SDK accepts an object with filter, sort, and page fields. The TypeScript StateQueryType requires all three. The following query finds blue widgets in either region and sorts by size:
const query = {
filter: {
AND: [
{ EQ: { color: "blue" } },
{ IN: { "warehouse.region": ["west", "east"] } },
],
},
sort: [{ key: "size", order: "ASC" }],
page: { limit: 10 },
};
const response = await client.state.query("state-mongodb", query);
for (const item of response.results) {
if (item.error) {
throw new Error(`Unable to read query result ${item.key}: ${item.error}`);
}
console.log(item.key, item.data, item.etag);
}
Use field paths into the stored document. The SDK does not translate filter paths through a custom serializer. For example, a serializer that wraps each value in a payload property requires filter paths matching that representation.
The SDK’s declared filter surface includes EQ, IN, AND, and OR. Sort entries accept ASC or DESC, with ascending order as the runtime default when omitted. The runtime query API documents additional operators, but do not assume they are represented in this SDK version’s top-level filter type.
For an unfiltered, unsorted page, use filter: {}, sort: [], and page: { limit: 10 }. A SQL string or an object containing language and query is not the StateQueryType accepted by this API.
In TypeScript, preserve literal sort orders when extracting a query into a variable, for example with order: "ASC" as const, or pass the query object directly to client.state.query.
Retrieve another page
The response contains results and an optional token. Treat the token as opaque and pass it back in page.token, retaining the original filter, sort, and page size:
if (response.token) {
const nextPage = await client.state.query("state-mongodb", {
...query,
page: { ...query.page, token: response.token },
});
for (const item of nextPage.results) {
if (item.error) {
throw new Error(`Unable to read query result ${item.key}: ${item.error}`);
}
console.log(item.key, item.data);
}
}
Continue until no continuation token is returned. There is no automatic paginator; an empty results array should not replace the token check.
Response and protocol considerations
Each query result has key, data, and optional etag and error. Check item errors as well as catching promise rejections.
With a configured serializer, the SDK deserializes successful, non-empty result values. Without one, HTTP returns the runtime’s JSON result data, while gRPC returns bytes. For known JSON data on gRPC without a serializer, explicitly decode a successful item’s data with JSON.parse(Buffer.from(item.data).toString("utf8")); do not apply that conversion to HTTP results that are already objects.
The declared response exposes results and token, not a portable response metadata map. query has no separate request metadata parameter. Store support, indexes, filter behavior, and pagination semantics remain component-dependent.
See state client usage for configuration, serialization compatibility, and error handling.