DEV Community

Cover image for What's New in StateAdapt 2.0.0
Pierre Bouillon for This is Angular

Posted on

What's New in StateAdapt 2.0.0

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 Error example
  • 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');
Enter fullscreen mode Exit fullscreen mode
  • With an adapter
const name = adapt(['name', 'John Doe'], {
  uppercase: name => name.toUpperCase(),
  selector: {
    firstLetter: name => name.at(0),
  },
});
Enter fullscreen mode Exit fullscreen mode
  • From a source
const name = adapt(
  ['name', 'John Doe'],
  http.get('/name').pipe(toSource('[Name] Get from HTTP')),
);
Enter fullscreen mode Exit fullscreen mode
  • 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')),
});
Enter fullscreen mode Exit fullscreen mode

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

mfp22 avatar
mfp22 posted on

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);
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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')),
});
Enter fullscreen mode Exit fullscreen mode

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$')),
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

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> {
Enter fullscreen mode Exit fullscreen mode

Discussion

What I like about this change:

  1. The new sources syntax taking a function for recursive sources. See #44
  2. The optional path, enabled by 1. For now it will show up in DevTools as 0, 1, etc. Automatically chosen path. And maybe it can get smarter over time. But if necessary, can specify path.
  3. Only 1 overload creates much better TS warnings.
  4. Simpler for newcomers.
  5. Leaves room for more options. 2 come to mind already: sinks and resetOnRefCount0 with the option to keep state in cache for a certain time after all unsubscribes, similar to the same option in share()
  6. It enables even more incremental syntax than before. It's a smaller gap from useState(0) or signal(0) to adapt(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',
});
Enter fullscreen mode Exit fullscreen mode

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),
  },
});
Enter fullscreen mode Exit fullscreen mode
  • 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')),
});
Enter fullscreen mode Exit fullscreen mode
  • 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')),
});
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
mfp22 profile image
Mike Pearson

Great explanation! I'll have to link back to this from the video.

Collapse
 
timsar2 profile image
timsar2

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.

Collapse
 
mfp22 profile image
Mike Pearson

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 with store.$state() rather than toSignal(store.state$)