react-rxjs icon indicating copy to clipboard operation
react-rxjs copied to clipboard

Is a more concise API possible?

Open bhootd opened this issue 1 year ago • 6 comments

I use this library to manage state in a project at work. I love it.

Though I wish that the react-rxjs integration interface were simpler and less verbose than it is.

Currently, a minimum of 4 variables need to be tracked for one state stream:

  • handleFn to dispatch events from react component
  • a stream that receives the events dispatched from a React component
  • a stream the access to which can be shared across React components
  • a useX hook to access a value from the shared stream

This hurdle becomes too much for newcomers.

Is there a way to simplify this interface?

bhootd avatar Oct 19 '24 11:10 bhootd

Is there a way to simplify this interface?

I think so. Could you please provide a concrete example so that we can suggest a less verbose approach? Or perhaps we can come up with a less verbose API.

josepot avatar Oct 19 '24 11:10 josepot

Sure, here is an example. Forgive any syntax error in the code; my original code is in ReScript (a language that transpiles to JavaScript), and I translated it to JavaScript here for convenience.

let [sValueChanged, handleValueChange] = createSignal(e => getEventValue(e));

let [useRawValue, ssRawValue] = bind(sValueChanged, '');

let [useParsedValue, ssParsedValue] = bind(
    ssRawValue.pipe(
      map(v => {
        // return Ok(int) or Error(string).
      }),
    ),
)

const Threshold = () => {
  const rawValue = useRawValue();
  const parsedValue = useParsedValue();
  const id = "ThresholdInput";
  return <div>
    <label htmlFor={id}>Threshold you would like to cover</label>
    <div>
      <input
        type="text"
        id={id}
        name={id}
        defaultValue={rawValue}
        onChange={handleValueChange}
      />
      <span>tonne/hectare</span>
    </div>
    {parsedValue.error && <p>{parsedValue.error}</p>}
  </div>
}

bhootd avatar Oct 19 '24 11:10 bhootd

Please let me know if you need more input from me.

bhootd avatar Oct 22 '24 16:10 bhootd

I personally prefer the newer API that uses useStateObservable hook instead - It's probably due to personal preference, but that decreases the amount of different variables you'd be using. With this you could rerwite it to:

import { state, useStateObservable } from '@react-rxjs/core'
import { createSignal } from '@react-rxjs/utils';

const [sValueChanged, handleValueChange] = createSignal(e => getEventValue(e));

const ssRawValue = state(sValueChanged, '');

const ssParsedValue = ssRawValue.pipeState(
   map(v => {
     // return Ok(int) or Error(string).
   }),
)

const Threshold = () => {
  const rawValue = useStateObservable(ssRawValue);
  const parsedValue = useStateObservable(ssParsedValue);
  const id = "ThresholdInput";
  return <div>
    <label htmlFor={id}>Threshold you would like to cover</label>
    <div>
      <input
        type="text"
        id={id}
        name={id}
        defaultValue={rawValue}
        onChange={handleValueChange}
      />
      <span>tonne/hectare</span>
    </div>
    {parsedValue.error && <p>{parsedValue.error}</p>}
  </div>
}

As for getting it more concise, something that we've seen a common pattern is the createSignal + state combo, but I feel like it's trivial to build abstractions that fill their own needs, so at the moment I'd rather not increase the API surface. But having something like:

export const createStatefulSignal = <T, I>(mapper: (input: I) => T, defaultValue: T) => {
  const [valueChange$, setValue] = createSignal(mapper);
  const state$ = state(valueChange$, defaultValue);

  return [state$, setValue]
}

Might be enough for your use case. But again, it's not something we want to add at this moment because there are different versions which might fit better different use cases (such as also exporting the valueChange$, in regards to the mapper, or defaultValue, or having it reset, etc.), so I think it's better to let everyone deal their own utilities for this, and leave React-RxJS with smaller composable primitives.

With this utility the code would be reduced to:

const [ssRawValue, handleValueChange] = createStatefulSignal(e => getEventValue(e), '');

const ssParsedValue = ssRawValue.pipeState(
   map(v => {
     // return Ok(int) or Error(string).
   }),
)

voliva avatar Oct 22 '24 19:10 voliva

Wow. So, state does the same thing as bind.

I personally prefer the newer API that uses useStateObservable hook instead.

I looked in the docs further, and saw this in the bind API page:

function bind<T>(
  observable: Observable<T>,
  defaultValue?: T,
) {
  const state$ = state(observable, defaultValue);

  return [
    () => useStateObservable(state$),
    state$
  ];
}

So, state is a lower-level abstraction used by bind, even though the former feels simpler to use.

My guess is when you referred to the state APIs as the newer API, you might have been talking about useStateObservable, not the state() function.

bhootd avatar Oct 23 '24 02:10 bhootd

createSignal + state combo : createStatefulSignal()

I've given this a shot, but as you described, this abstraction faltered for a few scenarios, like producing two state streams that depend on a single valueChange$.

However, now that I'm writing this, I realised that I can just use the shared stream returned by createStatefulSignal to produce two state streams. I'm missing something here.

Anyway, state + useStateObservable is a much more intuitive API than bind + useNameAHandlerForEveryStream API to me. A Jotai user would object less to the former API.

bhootd avatar Oct 23 '24 03:10 bhootd

In case this helps anyone, I came up with an abstraction tailored to my needs.

The original code is in ReScript, which supports pattern matching that makes the abstraction simpler. But a roughly translated TypeScript is as follows:

import { startWith } from "rxjs/operators";
import { state } from "@react-rxjs/core";

type FirstEmission<v> =
    | { t: 'SuspendUntilStreamEmits' }
    | { t: 'EmitAsFirstValue', v: v }

type WhoManagesSubscription<v> =
    | { t: 'ConsumerComponent', preSubscriptionValueForHook: v }
    | { t: 'SubscribeTreeRoot', firstEmission: FirstEmission<v> }

const stateX = (stream, whoManagesSubscription) => {
    switch (whoManagesSubscription.t) {
        case 'ConsumerComponent':
            return state(stream, whoManagesSubscription.preSubscriptionValueForHook)

        case 'SubscribeTreeRoot':
            switch (whoManagesSubscription.firstEmission) {
                case 'SuspendUntilStreamEmits':
                    return state(stream);

                case 'EmitAsFirstValue':
                    return state(stream.pipe(startWith(whoManagesSubscription.firstEmission.v)));
            }
    }
}

// usage
let sHandledByConsumerComponent = stateX(sNameChanged, { t: 'ConsumerComponent', preSubscriptionValueForHook: '' })
let sHandledBySubscribeRoot = stateX(sNameChanged, { t: 'SubscribeTreeRoot', firstEmission: { t: 'SuspendUntilStreamEmits' } })
let sHandledBySubscribeRootStartWith = stateX(sNameChanged, { t: 'SubscribeTreeRoot', firstEmission: { t: 'EmitAsFirstValue', v: '' } })

bhootd avatar Dec 16 '24 06:12 bhootd