diegohaz/constate

RFC: Hooks

diegohaz opened this issue · 1 comments

React hooks were announced this week and will be available in React 16.7. I'm opening this issue to talk about an addition (or maybe a replacement) on this library's API.

Purpose of this library

First, it's important to clarify the purpose of this library. A big part of the state of our applications should be local, and some should be shared, contextual or global (whatever people call it). But we don't always know whether a state should be local or shared when first writing it.

Having to refactor local state into Context or Redux later could be cumbersome. Many choose to write global state (using Redux, MobX, React Context etc.) upfront just because some day they may need it.

Constate makes it easier to write local state and make it global later.

And that's why it's called Constate. Context + State.

UPDATE: Final API

Provider

Provides state to its children;

import { Provider } from "constate";

function App() {
  return (
    <Provider devtools>
      <Counter />
    </Provider>
  );
}

useContextState

Same as React's useState, but accepting a context key argument so it accesses the shared state on Provider.

import { useContextState } from "constate";

function Counter() {
  const [count, setCount] = useContextState("counter1", 0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

useContextReducer will be available as well.

useContextEffect

Effect that runs on Provider so it will run only once per context key, not per consumer.

import { useContextState, useContextEffect } from "constate";

function Counter() {
  const [count, setCount] = useContextState("counter1", 0);
  
  useContextEffect("counter1", () => {
    document.title = count;
  }, [count]);

  return <div>{count}</div>;
}

Other methods such as useContextMutationEffect and useContextLayoutEffect will be available as well.

Context

The Context object will be exposed so users will be able to useContext directly to access the whole shared state tree:

import { useContext } from "react";
import { Context } from "constate";

function Counter() {
  const [state, setState] = useContext(Context);
  const incrementCounter1 = () => setState({
    ...state,
    counter1: state.counter1 + 1
  });
  return <button onClick={incrementCounter1}>{state.counter1}</button>;
}

createContext

Alternatively, a new context object can be created:

// context.js
import { createContext } from "constate";

const {
  Context,
  Provider,
  useContextState,
  useContextEffect
} = createContext({ counter1: 0 });

export {
  Context,
  Provider,
  useContextState,
  useContextEffect
};
import { useContextState } from "./context";

function Counter() {
  const [count, setCount] = useContextState("counter1");
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Alternative 1: useState / useEffect

Given the following example using normal React useState:

import { useState } from "react";

const CounterButton = () => {
  const [count, setCount] = useState(0);
  const increment = () => setCount(count + 1);
  return <button onClick={increment}>{count}</button>;
};

Say we want to share that count state between multiple components. My idea is to export an alternative useState method so people can pass an additional context argument to access a shared state.

import React from "react";
// import `useState` from constate instead of react
import { Provider, useState } from "constate";

const CounterButton = () => {
  // the only difference here is the additional argument
  const [count, setCount] = useState(0, "counter1");
  const increment = () => setCount(count + 1);
  return <button onClick={increment}>{count}</button>;
};

// Another component that accesses the same shared state
const CounterText = () => {
  const [count] = useState(0, "counter1");
  return <p>You clicked {count} times</p>;
};

const App = () => (
  <Provider devtools>
    <CounterText />
    <CounterButton />
  </Provider>
);

Also, it could be abstracted so as to provide something similar to what we have today:

// useCounter.js
import { useState } from "constate";

function useCounter({ initialState, context } = {}) {
  const [state, setState] = useState({ count: 0, ...initialState }, context);
  const increment = () => setState(prevState => ({ 
    ...prevState, 
    count: prevState.count + 1 
  });
  return { ...state, increment };
}

export default useCounter;
// Counter.js
import React from "react";
import useCounter from "./useCounter";

const Counter = () => {
  const { count, increment } = useCounter({ context: "counter1" });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={increment}>Click me</button>
    </div>
  );
}

Currently, we have some lifecycle props such as onMount, onUpdate and onUnmount. They serve not only as a nicer way to use lifecycles within functional components, but they also guarantee that they will trigger per context (if the prop is passed), and not per component.

Now we can use lifecycles within functions with useEffect, so Constate doesn't have to intrude on it anymore. But the second problem still persists.

As an example, consider a shared state that would fetch some data from the server in componentDidMount. If the state is shared, this should be triggered only once (when the first component gets mounted, for example), and not once per component that accesses that state.

I'm still not sure how to make it work. Maybe we would need to export an alternative useEffect to do the job.

Alternative 2: useConstate/useConstateEffect

Instead of useState and useEffect, we can just export useConstate and useConstateEffect:

import { useConstate, useConstateEffect } from "constate";

const Counter = () => {
  const context = "counter1";
  const [count, setCount] = useConstate({ initialState: 0, context });

  useConstateEffect({
    create: () => {
      document.title = count;
    },
    inputs: [count],
    context
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={increment}>Click me</button>
    </div>
  );
};

That's because the signatures can't be the same. When using context, most of the time we won't set initialState. Having it as the first argument would require us to write useState(undefined, "context") all the time, which can be cumbersome.

An object parameter solves this problem. The keys follow the parameter names of the original methods on React codebase.

Alternative 3: useConstate

import { useConstate } from "constate";

const Counter = () => {
  const { useState, useEffect } = useConstate("counter1");
  const [count, setCount] = useState(0);

  const increment = () => setCount(count + 1);

  useEffect(() => {
    document.title = count;
  }, [count]);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={increment}>Click me</button>
    </div>
  );
};
- import { useState, useEffect } from "react";
+ import { useConstate } from "constate";

  const Counter = () => {
+   const { useState, useEffect } = useConstate("counter1");
    const [count, setCount] = useState(0);

    const increment = () => setCount(count + 1);

    useEffect(() => {
      document.title = count;
    }, [count]);

    return (
      <div>
        <p>You clicked {count} times</p>
        <button onClick={increment}>Click me</button>
      </div>
    );
  };

Alternative 4: useContextState/useContextEffect

import { useContextState, useContextEffect } from "constate";

const Counter = () => {
  const [count, setCount] = useContextState("counter1", 0);

  const increment = () => setCount(count + 1);

  useContextEffect(
    "counter1", 
    () => {
      document.title = count;
    }, 
    [count]
  );

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={increment}>Click me</button>
    </div>
  );
};

This looks brilliant.