raveclassic / frp-ts

Functional reactive values-over-time
MIT License
80 stars 8 forks source link

@frp-ts/state addition #53

Open PalmZE opened 2 years ago

PalmZE commented 2 years ago

@raveclassic, hi!

Would you accept the below feature as a separate frp-ts package\addition to the core package?

// API

type StateActions<S> = Record<string, (this: S, ...args: any[]) => undefined | void>;

type OmitThis<AS extends StateActions<any>> = {
  [K in keyof AS]: AS[K] extends (this: any, ...args: infer U) => void | undefined ? (...args: U) => void : never;
};

export const newState = <S, AS extends StateActions<S>>(initial: S, actions: AS): Property<S> & OmitThis<AS> => {
  throw Error('impl is skipped');
};

// USAGE

interface Dog {
  name: string;
}
interface House {
  dog: Dog;
}
interface AppState {
  house: House;
}

const initialState: AppState = { house: { dog: { name: 'Fido' } } };

const store = newState(initialState, {
  renameTheDog(newName: string) {
    // this is typed correctly : AppState
    this.house.dog.name = newName;
  },
});

// typed correctly, this is omitted
store.renameTheDog('Odif');

Internally it will use immer to support concise mutation syntax.

Motivation

I find it convenient to group store\vm state into a single object and expose a single atom in the API. This leads to some boilerplate when you need to modify parts of the state. Immer helps a lot, but we still need to use modify calls. This change simplifies this use case.

Besides, some libraries provide this out of the box (SolidJS as example).

raveclassic commented 2 years ago

@PalmZE Looks interesting, I'll try to book some time today to digest this. In the meantime, I'm not a big fan of this, how about turning the methods into functions taking current mutable state as an argument?

raveclassic commented 2 years ago

Solid's implementation also looks nice and in fact it's much more composable

raveclassic commented 2 years ago

Ok, I played a bit with immer and came up with this solution https://github.com/raveclassic/frp-ts/pull/54 @PalmZE I don't think mixing methods with Atom interface is a good idea as it leads to possible name clashes (e.g. we might want to add a subscribe, set etc. methods which would overwrite original methods defined for Atom). Instead, I suggest working with method records directly. Still, you can create a bunch of methods and mix them into resulting Atom by hand.

interface Dog {
  name: string;
}
interface House {
  dog: Dog;
}
interface AppState {
  house: House;
}

const initialState: AppState = { house: { dog: { name: 'Fido' } } };
const storeState = newAtom(initialState)
const storeMethods = produceMany(storeState, {
  renameTheDog: (newName: string) => state => {
    state.house.dog.name = newName
  }
})

const store = { ...storeState, ...storeMethods }

store.renameTheDog('Odif');
raveclassic commented 2 years ago

Btw, any ideas for a better name for produceMany?

PalmZE commented 2 years ago

@raveclassic looks nice 👍

as for a better name, some ideas:

Anyway, I guess jsdoc explaining that the API uses immer internally will do

Fyzu commented 2 years ago

I love this concept! I thought immer would fit well here :)

produceMany is similar to immer naming and looks nice But I agree with PalmZE that we can choose any name

Fyzu commented 2 years ago

@raveclassic Might as well try making a variant with a reducer like approach?

const dispatch = newAtomReducer(state, (draft, action: Actions) => {
  if (action.type === "toggle") {
    const todo = draft.find((todo) => todo.id === action.id)
    todo.done = !todo.done
  }
})

dispatch({ type: "toggle", id: "1" })