arnelenero / simpler-state

The simplest app state management for React
https://simpler-state.js.org
MIT License
478 stars 16 forks source link

RFC: Can this get even simpler? Suggestions are most welcome. #1

Closed arnelenero closed 3 years ago

arnelenero commented 3 years ago

I claim "simplest" state management, so I want to make sure this library is true to its promise. So far this is the simplest I could think of, while still maintaining the functionality that I'm going for.

Any and all suggestions that will make this even simpler are much appreciated.

( Let's avoid comparisons with existing libraries, please. Being the "best" is never the goal of this library. 😂 )

d-Pixie commented 3 years ago

Might I suggest borrowing some syntax from the React state hook:

// counter.js

import { entity } from 'simpler-state'

export const [counter, setCounter] = entity(0)

export const increment = by => {
  setCounter(counter + by)
}

export const decrement = by => {
  setCountet(counter - by)
}

That way of returning pieces of state from the plugin is very flexible when extending as well. And it's already established with a large portion of the potential user base of this library.

arnelenero commented 3 years ago

Thanks for your comment @d-Pixie. That syntax would be neat, but the challenge there is that entity() would then have to behave much like useState which is a hook. In this case, entity() is not a hook, so it will not be able to supply the current value of counter that is referenced inside the actions. This syntax would have been possible if we have restricted the entities to have object (mutable) data types. But as you can imagine, this mutability won't play well with our state-change tracking. Besides, I really wanted to support primitive types as well. 😀

TheSwordBreaker commented 3 years ago

I Actully Love this But One Thing Want to Add.

  1. When Only Aim is to Fetch Data from api or graph-ql query then. I want to Do something like
import { load } from 'simpler-state'

const Url = "sampleUrl" // api endpiont or something

export const [row, setRows ,] = load(url)

// optinal export const [row, setRows , loading ,error ] = load(url)

export const add = by => {
  setRows([ ...row, by])
}

Use the load in your components with hooks

import { useLoad } from 'simpler-state'
import { row } from 'row'

const CounterView = () => {
  const [record , loader , error  ] = useLoad(row)

  return (
    <>
      { error ? (
           { loader? (
               <div>{error }</div>
           )? (

                rows.map((x,i) => <div key={i}>x </div>)

             )}
      )? (
        <div>{error }</div>
      )  } 
    </>
  )
}
TheSwordBreaker commented 3 years ago

Add some react query favour to this global state and make more easier to acces it in more than one components

arnelenero commented 3 years ago

Great stuff there @TheSwordBreaker.

To keep simplicity and avoid deviating too much from a "single extensible format", we can probably take that idea one step at a time. Let me know if this one would take us one step closer to your goal...

// entities/list.js
import { entity } from 'simpler-state'
import { fetchList } from './services/list'

export const list = entity(fetchList('some/url'))

where fetchList is any async loader that returns a Promise containing the remote data. In this example, it is a separate service so that we decouple the implementation details of the API access (e.g. security headers).

The entity would remain undefined until the Promise resolves (or throws/rejects). For flexibility, it is up to the developer how to handle errors, for example, an error flag in the list value.

Then the entity can be used in components in the normal way:

import { list } from './entities/list'

const ListView = () => {
  const listItems = list.use()

  return (
    <>
      {listItems ? (
        <>{/* display list items */}</>
      ) : (
        <> {/* display wait state, e.g. spinner */}</>
      )}
    </>
  )
}

This is what's already in the works for the library, so I would be interested to know if you think this is a step closer to what you had in mind. Let me know please.

UPDATE: The above functionality (async initial value a.k.a. prefetching) is now officially supported and is documented here: https://simpler-state.js.org/recipe-promise-init.html

dericcain commented 3 years ago

I have not tested this out, but it would be nice if only the components that relied on part of the state that changed were the updates that re-rendered when state changes were made. For instance, say we have this data shape:

const state = {
  first: 'jon',
  last: 'doe',
  postal: '35211',
};

And now we have three components - <A first={state.first} />, <B last={state.last} />, and <C postal={state.postal} />.

Now, we update state.first and we expect that only <A /> component would re-render since that is the only part of the state that was changed. This could fix the inherit problems that things like React Context have.

One solution to do this may to be able to select a piece of state. Possibly something like const first = useSelector(state.first), or something similar.

I'm not sure this makes things simpler but to have this type of performance out of the box would be a huge win.

arnelenero commented 3 years ago

@dericgw Thanks for your reply.

Happy to say that what you've requested is indeed supported: https://simpler-state.js.org/recipe-transforms.html 😀

arnelenero commented 3 years ago

Hi @jasonmichaels . Thanks for the comment. And I really appreciate your time looking at the code.

My response to the concerns you highlighted:

  1. The logic was written on the basis that Javascript arrays are technically objects.
  2. For the semantics of "shallow equal", we only look at equality of properties, no matter the object types.
  3. The expression Object.keys([]) should not throw, because an array is a valid object.
  4. I really intended that shallowEqual([1, 2], [1, 2]) to be true. This just means that the two arrays have the same items. Technically speaking, array items in javascript are like "properties" considering an array is an object. The keys for such properties would just be the indexes 0, 1, and so on.

Again, thanks a lot for your feedback.

TheSwordBreaker commented 3 years ago

Great stuff there @TheSwordBreaker.

To keep simplicity and avoid deviating too much from a "single extensible format", we can probably take that idea one step at a time. Let me know if this one would take us one step closer to your goal...

// entities/list.js
import { entity } from 'simpler-state'
import { fetchList } from './services/list'

export const list = entity(fetchList('some/url'))

where fetchList is any async loader that returns a Promise containing the remote data. In this example, it is a separate service so that we decouple the implementation details of the API access (e.g. security headers).

The entity would remain undefined until the Promise resolves (or throws/rejects). For flexibility, it is up to the developer how to handle errors, for example, an error flag in the list value.

Then the entity can be used in components in the normal way:

import { list } from './entities/list'

const ListView = () => {
  const listItems = list.use()

  return (
    <>
      {listItems ? (
        <>{/* display list items */}</>
      ) : (
        <> {/* display wait state, e.g. spinner */}</>
      )}
    </>
  )
}

This is what's already in the works for the library, so I would be interested to know if you think this is a step closer to what you had in mind. Let me know please.

UPDATE: The above functionality (async initial value a.k.a. prefetching) is now officially supported and is documented here: https://simpler-state.js.org/recipe-promise-init.html

yup it is ok

TheSwordBreaker commented 3 years ago

The Change I like to Add Optinal

import { list } from './entities/list'

const ListView = () => {
  const [ listItems, error] = list.use()

  if(error) return `${error}`;

  return (
    <>
      {listItems ? (
        <>{/* display list items */}</>
      ) : (
        <> {/* display wait state, e.g. spinner */}</>
      )}
    </>
  )
}
johnsonthedev commented 3 years ago

I would love to have a better way to deal with nested data. e.g. if I have an array of objects and I want to change one I have to slice it first :)


export const store = entity([
  { id: 1, status: "New", label: "erster" },
  { id: 2, status: "In Process", label: "zweiter" },
  { id: 3, status: "New", label: "dritter" },
])

export default () => {
  const state = store.use()

  const onChange = (index, val) => {
    const newStore = state.slice()
    newStore[index].lastChanged = val
    store.set(newStore)
  }
}
arnelenero commented 3 years ago

I would love to have a better way to deal with nested data. e.g. if I have an array of objects and I want to change one I have to slice it first :)


export const store = entity([
  { id: 1, status: "New", label: "erster" },
  { id: 2, status: "In Process", label: "zweiter" },
  { id: 3, status: "New", label: "dritter" },
])

export default () => {
  const state = store.use()

  const onChange = (index, val) => {
    const newStore = state.slice()
    newStore[index].lastChanged = val
    store.set(newStore)
  }
}

Thanks for your feedback @Johnson444.

You can more easily work with deep mutations with existing SimpleR State constructs, plus a state-proxy library of your choice. Here's an example using Immer:

import { entity } from 'simpler-state'
import proxy from 'immer'

export const store = entity({ items: [] })

export const doChangeItemLabel = (index, label) => {
  store.set(changeItemLabel, index, label)
}

const changeItemLabel = proxy((value, index, label) => {
    value.items[index].label = label
    value.items[index].lastChanged = Date.now()
})

Since SimpleR State supports entities of any type, including primitives, I can't make the proxied syntax the default one. But I believe it applies to your specific use-case.

The pattern above where the "pure" state changes are separated from the action functions is documented here: https://simpler-state.js.org/recipe-pure.html

Update: This usage has been included in the doc website here: https://simpler-state.js.org/recipe-proxies.html

Hubro commented 3 years ago

I read through the docs and I don't see any mention of SSR. As SSR is gradually becoming the norm, I think it would be prudent to add a page about how this library works in a SSR setup, any challenges or things that must be kept in mind.

I very often find that libraries like this look amazing on paper, but then immediately break when I add it to a Next.js app, which does SSR by default. Documenting SSR support in the docs would definitely make people like me a lot more likely to give the project a shot.

arnelenero commented 3 years ago

I read through the docs and I don't see any mention of SSR. As SSR is gradually becoming the norm, I think it would be prudent to add a page about how this library works in a SSR setup, any challenges or things that must be kept in mind.

I very often find that libraries like this look amazing on paper, but then immediately break when I add it to a Next.js app, which does SSR by default. Documenting SSR support in the docs would definitely make people like me a lot more likely to give the project a shot.

Thanks for those great inputs. Yes, certainly I can add that to the docs. Although I don't foresee issues on SSR given the types of hooks that I'm using and the very basic structure of the library code, I feel additional testing is still warranted to fully claim SSR support in the docs.

Again I appreciate the feedback.

Hubro commented 3 years ago

I read through the docs and I don't see any mention of SSR. As SSR is gradually becoming the norm, I think it would be prudent to add a page about how this library works in a SSR setup, any challenges or things that must be kept in mind.

I very often find that libraries like this look amazing on paper, but then immediately break when I add it to a Next.js app, which does SSR by default. Documenting SSR support in the docs would definitely make people like me a lot more likely to give the project a shot.

Thanks for those great inputs. Yes, certainly I can add that to the docs. Although I don't foresee issues on SSR given the types of hooks that I'm using and the very basic structure of the library code, I feel additional testing is still warranted to fully claim SSR support in the docs.

Again I appreciate the feedback.

A couple of issues I expect:

arnelenero commented 3 years ago

Thanks again @Hubro for that insightful feedback.

As for the persistence plug-in, it prevents crashes when localStorage is not available or undefined, which is the main reason why I recommend passing string "local" or "session" to indicate which type of web storage to use. You are right, this causes inconsistencies between server and client, so my recommendation for this could be to implement a custom plug-in, which the library makes easy to do, that would synchronize the browser storage with the server storage. I do not provide this as bundled because the library is agnostic to the persistence used on the server side.

But to your point, recommendations like the above should be added to the documentation. So thanks a lot for bringing this up!

Btw, I'm not a fan (yet?) of Next.js and SSR, but that is a completely personal bias. So I would probably open a "Help Needed" ticket so that folks here on GH who actively work on projects using SSR can contribute by doing tests, and sending a list of specific challenges in SSR, with some sample code maybe, and so I can document a recommended solution for the library. Definitely the ones you mentioned will be included in my list.

Hubro commented 3 years ago

I think a necessity for implementing proper SSR support in SimpleR State would be to add a couple of functions to dump- and load the entire internal state of the library. That way, after the page is rendered on the server side, it could dump and transfer the state of SimpleR State to the client, which could use this dump to set the global state of SimpleR State. When this is done, it should skip all init hooks on the client.

Perhaps:

For developer sanity, loadGlobalState should probably fail and throw an error if it's run after an init hook has already been run locally.

This wouldn't help with the localStorage issue though. That would probably have to be solved with a specific plugin for each framework.

arnelenero commented 3 years ago

I think a necessity for implementing proper SSR support in SimpleR State would be to add a couple of functions to dump- and load the entire internal state of the library. That way, after the page is rendered on the server side, it could dump and transfer the state of SimpleR State to the client, which could use this dump to set the global state of SimpleR State. When this is done, it should skip all init hooks on the client.

Perhaps:

  • dumpGlobalState
  • loadGlobalState

For developer sanity, loadGlobalState should probably fail and throw an error if it's run after an init hook has already been run locally.

This wouldn't help with the localStorage issue though. That would probably have to be solved with a specific plugin for each framework.

My only dilemma with that approach is: Would the benefit justify the overhead of serializing the entire app state from server and passing it to client (by cookies?), whereas probably majority of this app state would not involve an async fetch from server and therefore wouldn't be too expensive for a React.hydrate to just re-run. But it probably makes sense to do this electively, particularly for entities that are fetched from remote on init.

What do you think?

Hubro commented 3 years ago

My only dilemma with that approach is: Would the benefit justify the overhead of serializing the entire app state from server and passing it to client (by cookies?), whereas probably majority of this app state would not involve an async fetch from server and therefore wouldn't be too expensive for a React.hydrate to just re-run. But it probably makes sense to do this electively, particularly for entities that are fetched from remote on init.

What do you think?

I think it's probably worth it. My sense is that any time the app state contains a large amount of data, that data has probably been fetched from a remote database (right?) and this is precisely the situation where transferring this to the client and skipping init would be a huge boost in load time.

In Next.js, data is transferred from the server to the client using some special functions called getServerSideProps or getInitialProps. The former is always executed on the server, the latter is executed on the server in the case of SSR and otherwise executed in the client. When these functions are executed during SSR, the data they return are serialized and embedded in the HTML document and used when hydrating the app on the client side. No cookies involved.

I can't see a scenario where any non-remote data would contribute more than a couple of kB's of overhead to the HTML document, considering compression :thinking:

However! dumpGlobalState and loadGlobalState would of course be completely optional to use. They would make a lot of sense in a framework like Next.js, but I can imagine there being other SSR implementations where they might not. It wouldn't hurt to also have more granular ways of dumping/restoring single entities. The more extensibility the better :slightly_smiling_face:

arnelenero commented 3 years ago

@Hubro As always, I appreciate all the insightful inputs.

arnelenero commented 3 years ago

Thanks to everyone that participated in this RFC. With the release of v1.0.0, this RFC is officially closed.

Ongoing enhancements based on suggestions here (and from other channels) that did not make it to v1.0.0 (mostly because we wanted to lock the API in the Release Candidate as much as possible) will certainly be included in future releases.