raveclassic/frp-ts

@frp-ts/state addition

PalmZE opened this issue · 7 comments

@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).

@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?

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

Ok, I played a bit with immer and came up with this solution #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');

Btw, any ideas for a better name for produceMany?

@raveclassic looks nice 👍

as for a better name, some ideas:

  • newActions
  • newSetters
  • newReducer (handler functions ~ events)

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

Fyzu commented

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

@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" })