Three weeks ago, StateAdapt released a new major version.
The main changes here is a rework of the adapt
API that might have been confusing to some, especially newcomers.
Today we will have a brief overview of those breaking changes.
If you are more interested in watching than reading, take a look at Mike Pearson's video on the new version.
Creating an adapter in 1.x
Initially, there was 4 overloads of adapt
, each offering various possibilities:
adapt(path, initialState)
adapt([path, initialState], adapter)
adapt([path, initialState], sources
adapt([path, initialState, adapter], sources)
While the array syntax is consise and helps to reduce lines of code, having four overloads comes with a little bit of trouble:
- If you are doing something wrong, TypeScript might not be of a great help because of that, outputing confusing error messages
- Creating a new adapter when joining in a project and not having prior experience with StateAdapt could also be a bit frustrating until you get used to the syntax
Let's see how we called an adapter previously in the first version of StateAdapt:
- Single value
const name = adapt('name', 'John Doe');
- With an adapter
const name = adapt(['name', 'John Doe'], {
uppercase: name => name.toUpperCase(),
selector: {
firstLetter: name => name.at(0),
},
});
- From a source
const name = adapt(
['name', 'John Doe'],
http.get('/name').pipe(toSource('[Name] Get from HTTP')),
);
- With an adapter and a source
const nameAdapter = createAdapter<string>()({
uppercase: name => name.toUpperCase(),
selector: {
firstLetter: name => name.at(0),
},
});
const name = adapt(['name', 'John Doe', nameAdapter], {
set: http.get('/name').pipe(toSource('[Name] Get from HTTP')),
});
What v2 is about
Well aware of the issues induced by the plurality of the adapt
API, Mike Pearson opened a issue about this, with the goal of discussing how to unify the four overloads into a single one:
Explore removing overloads for StateAdapt.adapt #45
The API for StateAdapt.adapt
has been mostly the same for 2 years. But I've received feedback from a few people that the overloads are confusing. I've seen that the TypeScript errors can be very confusing as well. A couple of people have also said they want path
to be optional, and the current syntax would make that very difficult.
So, I believe StateAdapt.adapt should move to only 1 overload, with 3 possibilities for 2nd argument: undefined, adapter or options. Here is each existing overload and the new syntax:
1. adapt(path, initialState)
// old
const count1 = adapt('count1', 4);
// new
const count1 = adapt(4);
2. adapt([path, initialState], adapter)
// old
const count2_2 = adapt(['count2_2', 4], {
increment: count => count + 1,
selectors: {
isEven: count => count % 2 === 0,
},
});
// new
const count2_2 = adapt(4, {
increment: count => count + 1,
selectors: {
isEven: count => count % 2 === 0,
},
});
3. adapt([path, initialState], sources)
// old
const count3 = adapt(
['count3', 4],
http.get('/count/').pipe(toSource('http data')),
);
// new
const count3 = adapt(4, {
sources: http.get('/count/').pipe(toSource('http data')),
});
4. adapt([path, initialState, adapter], sources)
// old
const adapter4 = createAdapter<number>()({
increment: count => count + 1,
selectors: {
isEven: count => count % 2 === 0,
},
});
const count4 = adapt(['count4', 4, adapter4], watched => {
return {
set: watched.state$.pipe(delay(1000), toSource('tick$')),
};
});
// new
const count4 = adapt(4, {
path: 'count4',
adapter: {
increment: count => count + 1,
selectors: {
isEven: count => count % 2 === 0,
},
},
sources: watched => {
return {
set: watched.state$.pipe(delay(1000), toSource('tick$')),
};
},
});
Implementation
I had to use a trick to get inference to work. Here's the type implementation:
adapt<State, S extends Selectors<State>, R extends ReactionsWithSelectors<State, S>>(
initialState: State,
second?: (R & { selectors?: S, adapter?: never, sources?: never, path?: never }) | {
path?: string;
adapter?: R & { selectors?: S };
sources?: SourceArg<State, S, R>;
}
): SmartStore<State, S & WithGetState<State>> & SyntheticSources<R> {
Discussion
What I like about this change:
- The new
sources
syntax taking a function for recursive sources. See #44 - The optional
path
, enabled by 1. For now it will show up in DevTools as0
,1
, etc. Automatically chosen path. And maybe it can get smarter over time. But if necessary, can specify path. - Only 1 overload creates much better TS warnings.
- Simpler for newcomers.
- Leaves room for more options. 2 come to mind already:
sinks
andresetOnRefCount0
with the option to keep state in cache for a certain time after all unsubscribes, similar to the same option inshare()
- It enables even more incremental syntax than before. It's a smaller gap from
useState(0)
orsignal(0)
toadapt(0, { increment: n => n + 1 })
.
What I hate about it: It's a breaking change. I will try to find a way to make migrating easier. I myself would benefit from migration tools because I have so many projects I will want to update.
Plans
Before this, I will add the new source function syntax inspired by #44, add the new injectable function from that discussion, and release that as 1.2.0. Then I'll fix #38 and release that in 1.2.1.
Since this issue is a breaking change in a main feature, I will make this a major version bump to StateAdapt 2.0.
All of this can be done before adding signal features for Angular, which will require Angular 16+, so I will release that in version 2.1.
As a result, adapt
now only requires an initial value and a second, optional, configuration object takes care of specifying any additional behaviour.
This also means that this version is a breaking change, hence the bump in the major digit
Usage and migration
Let's see how this migration will impact the current code:
- Single value
// v1
const name = adapt('name', 'John Doe');
// v2
const name = adapt('John Doe', {
path: 'name',
});
Optionally, since the configuration object is optional, defining a path is no longer required:
const name = adapt('John Doe');
- With an adapter
// v1
const name = adapt(['name', 'John Doe'], {
uppercase: name => name.toUpperCase(),
selector: {
firstLetter: name => name.at(0),
},
});
// v2
const name = adapt('John Doe', {
path: 'name',
uppercase: name => name.toUpperCase(),
selector: {
firstLetter: name => name.at(0),
},
});
- From a source
// v1
const name = adapt(
['name', 'John Doe'],
http.get('/name').pipe(toSource('[Name] Get from HTTP')),
);
// v2
const name = adapt('John Doe', {
path: 'name',
sources: http.get('/name').pipe(toSource('[Name] Get from HTTP')),
});
- With an adapter and a source
// v1
const nameAdapter = createAdapter<string>()({
uppercase: name => name.toUpperCase(),
selector: {
firstLetter: name => name.at(0),
},
});
const name = adapt(['name', 'John Doe', nameAdapter], {
set: http.get('/name').pipe(toSource('[Name] Get from HTTP')),
});
// v2
const name = adapt('John Doe', {
path: 'name',
// 👇 If needed, the adapter can be created locally
adapter: {
uppercase: name => name.toUpperCase(),
selector: {
firstLetter: name => name.at(0),
},
},
sources: http.get('/name').pipe(toSource('[Name] Get from HTTP')),
});
We can still define the adapter elsewhere and use it only when calling
adapt
:const nameAdapter = createAdapter<string>()({ uppercase: name => name.toUpperCase(), selector: { firstLetter: name => name.at(0), }, }); const name = adapt('John Doe', { path: 'name', // 👇 Using an existing adapter adapter: nameAdapter, sources: http.get('/name').pipe(toSource('[Name] Get from HTTP')), });
Personal thoughts
I discovered StateAdapt a couple of months ago through a video of Joshua Morony and it gained my interest.
However, after diving into it, I was quickly lost between the overloads of the library, and it was making the shift from other state management libraries such as NgRx harder.
With this update, I find the unified synthax way more accessible, and easier to get started with.
Mike Pearson is doing an awesome work with this and I hope StateAdapt will continue to grow!
If you would like to give it a try, he has a Youtube channel full of resources, and a great overview of the new version:
If you wish to migrate, he also recorded a step by step migration example using all the overloads.
I hope you learned something useful there!
Cover image from StateAdapt's website
Top comments (3)
Great explanation! I'll have to link back to this from the video.
I'm a bit confused on how to use signal base component and state-adapt. I hope I do not implement it as anti-patern inside a nx project.
Does Angular have signal components yet? I thought that was going to be v18.
Anyway, syntactic sugar for signals is coming soon, but just calling
toSignal
inside a component on an observable should just work. Just think of toSignal as the new async pipe, and soon you'll be able to use a signal directly withstore.$state()
rather thantoSignal(store.state$)