ibiza

1.4.0 • Public • Published

Ibiza - React State Management for Party Animals

Ibiza gets out of your way and simply lets you read and write state as you would with regular Javascript assignment. It maintains a global state object and is smart enough to know when to rerender your components based on state usage and modification.

useIbiza()

A component calls the useIbiza React hook, which returns the store state as a JS Proxy object. This Proxy records when a state property is accessed or "used". Then when that property is mutated, the component is rerendered.

This means that you can mutate your state in any way, but your component will only rerender if you mutate state that you have used.

The following component will rerender when the button is clicked, because name has changed.

import { useIbiza } from 'ibiza'

const App = () => {
  const state = useIbiza()

  return (
    <div>
      <h1>Hello {state.name}</h1>
      <button onClick={() => void (state.name = 'Joel')}></button>
    </div>
  )
}

The following component will not rerender when the button is clicked, because even though age has been mutated, it is not actually being used within the component.

import { useIbiza } from 'ibiza'

const App = () => {
  const state = useIbiza()

  return (
    <div>
      <h1>Hello {state.name}</h1>
      <button onClick={() => void (state.age = 44)}></button>
    </div>
  )
}

State can be mutated from anywhere - not just from within a component:

import { useIbiza, store } from 'ibiza'

const App = () => {
  const state = useIbiza()

  return (
    <div>
      <h1>Hello {state.name}</h1>
    </div>
  )
}

// <App> component will re-render when this line executes, as `name` has changed.
store.state.name = 'Ash'

Initial State

You can pass some initial state to useIbiza which will be merged with existing state in the Ibiza store.

const App = () => {
  const state = useIbiza({ user: { name: 'Joel' } })

  return <h1>Hello {state.user.name}</h1>
}

This is safe to include in the body of your component, as it will only be merged into the store on first render.

Slicing

Ibiza supports deeply nested objects, allowing you to design the shape of your state any way you like. To help you work with such nested state, the useIbiza hook supports slicing.

The following example assumes you have an existing state object:

{
  user: {
    name: 'Joel',
    partner: {
      name: 'Sam',
    }
  }
}

Pass a string to useIbiza to fetch a specific slice of state. The string should be the path to the object that you want.

const App = () => {
  const partner = useIbiza('user.partner')

  return (
    <div>
      <h1>Hello {partner.name}</h1>
      <button onClick={() => void (partner.name = 'Bob')}></button>
    </div>
  )
}

Note that slicing is really only supported as a convenience, and will not generally help with performance.

createModel()

Ibiza is at its most powerful when working with models. They provide reusability and flexibility.

Create a model using the createModel helper, where the first argument is the model name, and the second argument is the initial model state as a plain object.

// model.js

export default createModel('user', {
  firstName: 'Joel',
  lastName: 'Moss',
  children: [{ firstName: 'Ash' }, { firstName: 'Elijah' }, { firstName: 'Eve' }]
})

createModel returns a hook which you can use. You can now import your model anywhere:

// app.jsx

import useModel from './model'

const App = () => {
  const user = useModel()

  return (
    <div>
      <h1>Hello {user.firstName}</h1>
      <ul>
        {user.children.map((child, i) => (
          <li key={i}>{child.firstName}</li>
        ))}
      </ul>
    </div>
  )
}

Initial State

Sometimes you want to be able to define your model with some initial state that could be provided dynamically. For example, from component props or some other parameters.

createModel can accept a function as its second argument. This function will be called on initialisation with two arguments; the current state and any props passed to it when its hook is first called.

// model.js

export default createModel('user', (state, props) => ({
  firstName: 'Joel',
  lastName: 'Moss'
  ...props
}))
// app.jsx

import useModel from './model'

const App = ({ yearOfBirth }) => {
  const user = useModel({ yearOfBirth })

  return (
    <div>
      <h1>
        Hello {user.firstName}, you were born in {user.yearOfBirth}.
      </h1>
    </div>
  )
}

Functions

You can use regular function in your model. They accept the current state as the first argument, and this will also be the current state (as long as you don't use hash rocket functions).

const App = () => {
  const state = useIbiza({
    count: 0,
    increment: state => {
      ++state.count
    },
    decrement() {
      ++this.count
    }
  })

  return (
    <>
      <h1>Count = {state.count}</h1>
      <button onClick={state.increment}>Increment</button>
      <button onClick={state.decrement}>Decrement</button>
    </>
  )
}

When reading/writing state within a function, this responds to the state at the level where the function sits. This means that if the function is deeply nested, this will not include state lower down.

To solve this, you can call this.$model to access your model, or this.$root to access the root of the store state.

useIbiza({
  deepstate: {
    user: {
      name: 'Joel',
      partner: {
        name: 'Sam',

        get partner() {
          // `this` refers to 'deepstate.user.partner'.
          // `this.$model` refers to 'deepstate.user'.
          // `this.$root` is the whole store state.
          return this.$model.name
        }
      }
    }
  }
})

Functions accept any number of arguments just like regular functions do, but don't forget that the first argument is always the state.

const App = () => {
  const user = useIbiza({
    children: [],
    addChild(state, firstName, lastName) {
      state.children.push({ firstName, lastName }) // or `this.children.push(...)`
    }
  })

  return (
    <>
      <ul>
        {user.children.map((child, i) => (
          <li key={i}>
            {child.firstName} {child.lastName}
          </li>
        ))}
      </ul>
      <button onClick={() => user.addChild('Ash', 'Moss')}></button>
    </>
  )
}

Getters and Setters

Ibiza supports Javascript getters and setters in your state and models.

export default createModel('user', {
  firstName: 'Joel',
  lastName: 'Moss',

  get fullName() {
    // You can read/write to your state via `this`.
    return `${this.firstName} ${this.lastName}`
  },

  set fullName(value) {
    const [firstName, lastName] = value.split(' ')

    this.firstName = firstName
    this.lastName = lastName
  }
})
import useModel from './user_model'

const App = () => {
  const user = useModel()

  return (
    <>
      <h1>Hello {user.fullName}</h1>
      <button onClick={() => void (user.fullName = 'Bob Bones')}></button>
    </>
  )
}

Async Functions

Functions can be async and/or return Promises, and support React's Suspense by default.

export default createModel('user', {
  name: 'Joel',

  async doSomething() {
    return await someAsyncAction()
  }
})
import useModel from './user_model'

const App = () => {
  const user = useModel()

  return (
    <>
      <h1>Hello {user.fullName}</h1>
      <button onClick={user.doSomething}></button>
    </>
  )
}

URL Models

Because reading and writing to/from the server is so common, Ibiza has support to make this easy with URL Models, similar to react-query. Simply use a valid and relative URL as your model name or slice.

The following will fetch the user from the server at /user, suspending the component while it does so. The data is fetched only when it does not exist in the store.

const App = () => {
  const user = useIbiza('/user')

  return <h1>Hello {user.name}</h1>
}

You can safely mutate your model, as all mutations will remain local.

You can write your model state back to the server. It the server responds with JSON, the model will be updated with that response.

const App = () => {
  const user = useIbiza('/user')

  const renameUser = async () => {
    await user.save({ body: { name: 'Bob' } })
  }

  return (
    <>
      <h1>Hello {user.name}</h1>
      <button onClick={renameUser}></button>
    </>
  )
}

URL Models can also be defined with createModel, and accept the same arguments:

export default createModel('/user')

createModel returns a hook which you can use. You can now import your model anywhere:

import useModel from './user_model'

const App = () => {
  const user = useModel()

  return <h1>Hello {user.firstName}</h1>
}

Additionally, URL models accept a fetcher option, which when provided, will be used as the fetch function for the model:

export default createModel('/user', {}, { fetcher: myFetchFunction })

Readme

Keywords

Package Sidebar

Install

npm i ibiza

Weekly Downloads

36

Version

1.4.0

License

MIT

Unpacked Size

297 kB

Total Files

12

Last publish

Collaborators

  • joelmoss