Skip to main content
Sharing state is the process of letting many features have access to the same data so that when any feature makes a change, it is instantly visible to every other feature. The Composable Architecture provides tools for sharing state with many parts of your application.

Understanding shared state

Because the Composable Architecture prefers modeling domains with value types rather than reference types, sharing state can be tricky. The library comes with tools from the Sharing library to handle this.
There are two main kinds of shared state: explicitly passed state and persisted state. And there are 3 persistence strategies: in-memory, user defaults, and file storage.

Explicit shared state

This is the simplest kind of shared state. It allows you to share state amongst many features without any persistence. The data is only held in memory.
1

Define shared state in parent

2

Accept shared reference in child

3

Pass shared reference

Now any mutation the child makes to count will be instantly reflected in the parent’s count too.

Persisted shared state

Sometimes you want to share state with the entire application without passing it around explicitly.

In-memory persistence

Keeps data in memory and makes it available everywhere, but doesn’t persist across app launches:
When using a persistence strategy with @Shared, you must provide a default value.

App Storage (User Defaults)

Automatically persists changes to user defaults:
This works for simple data types: strings, booleans, integers, doubles, URLs, data, and more.

File storage

For complex data types, use file storage with JSON serialization:
The value must conform to Codable when using file storage.

Custom persistence

You can create custom persistence strategies by conforming to SharedKey:
Then use it:

Observing changes

The @Shared property wrapper exposes a publisher to observe changes:
Be careful not to create infinite loops when both holding shared state and subscribing to changes.

Initialization rules

Property wrappers have special initialization rules. Here are the patterns:
The initializer takes a Shared value:
The initializer takes a plain value:
Use the wrappedValue initializer:

Deriving shared state

You can derive shared state for sub-parts of existing shared state:
When constructing the child:
This derives a Shared<String> from Shared<SignUpData>, allowing features to hold only the minimum shared state they need.

Concurrent mutations

Shared state is technically a reference, which means race conditions are possible. Use withLock to mutate safely:
This locks the entire unit of work: reading the current count, incrementing it, and storing it back.
Wrap as many mutations as possible in a single withLock to ensure the full unit of work is guarded by a lock.

Testing shared state

Shared state behaves differently from regular state but can still be tested exhaustively:
The TestStore and @Shared type work together to snapshot state before and after actions, allowing exhaustive assertions.

Testing with effects

If shared state is mutated in an effect:
You must explicitly assert on the shared state after the effect:

Testing with persistence

The .appStorage and .fileStorage strategies do extra work for testing:
  • .appStorage uses a non-persisting user defaults by default
  • .fileStorage uses a mock file system
This means tests don’t persist data across runs:

Type-safe keys

Add type safety by extending SharedReaderKey:
Now you can use it with compile-time type checking:
You can even bake in the default:

Best practices

Use explicit sharing

Prefer passing @Shared references explicitly over global persistence strategies

Lock mutations

Always use withLock when mutating shared state

Derive when possible

Use derived shared state to give child features minimal access

Type-safe keys

Create type-safe keys for better compiler checking

Read-only shared state

For read-only access, use @SharedReader:
This is useful for remote configuration files or other data that shouldn’t be mutated locally.