monarchwadia / steelbit

2 stars 0 forks source link

State management #2

Open monarchwadia opened 1 year ago

monarchwadia commented 1 year ago

We didn't talk about this in great detail on our call. But, here are some random thoughts...

Pubsub: To keep things efficient, I think we will need a pub/sub mechanism of some sort in runtime. When the component is mounted, it subscribes to a store; when it is unmounted, it is removed from the pub/sub of the store. Thinking about it, I'm realizing we have 2 ways to do this.... 1 way is to explicitly require the name of the store to be specified on the component in the WYSIWYG.... the 2nd way to do it might be to parse the component, including any javascript, and dynamically figure out whether a state is being used. I haven't thought about this too clearly, but there's that. Idk, maybe I'm overthinking it / prematurely optimizing, but I do see a need for this at least down the line.

Filespec: This is relatively straightforward once DI is established as per this issue: #1 . We just dependency-inject the store into a component that is using it.

Iteration based on state: Open question: How do we express in the definition file that we want to render multiple components based on a state array? For example, a list of posts?

Local v/s global state: How do we maintain local component state v/s global state that is shared between components? Is there even any meaningful difference here?

pldilley commented 1 year ago

Continuing along the lines of treating UI rendering and state separately - one idea is to treat state as a first class citizen on par with UI components.

IMO, the way React has done state has several disadvantages:

  1. Props often just come from a higher state. The problem here is that if a component is temporarily removed (i.e. replaced with a loader), it often loses any current local state and re-initializes with the props.

An example is a form component embedded in a modal. When you click save, the form is replaced with a spinning loader. An error occurred, and an error toast shows and the form is rendered again. Unfortunately since it was removed and re-added, it lost the changes the user made, and displays the initial state as provided by the props.

Usually this is solved by storing the state above the form component in the parent, or in a (store) context. So now we had to introduce complexity for something rather basic.

  1. Components can't share state, without being tied together. If you want to share data in React between components, you either do some prop drilling stuff, or you have to create a specific context. Problem with both: Your components become less and less re-usable, less testable and more coupled together.

  2. Child components have to use callbacks to control parent state. Which is actually fine. But wouldn't it be better to have callbacks on state instead?

So here's the proposal: All "props", "state" and "context" are handled entirely by lightweight contexts.

Basic example:

const sampleData: (Component | Context)[] = [{
// Example of a wider spread context
contextId: "globalContext",
type: 'SIMPLE',
state: {
  count: 0
},
actions: {
  incrementCount: "function() { this.count = this.count + 1; }"
},
children: [
  {
    id: 1,
    type: "div",
    style: {
      direction: "horizontal",
      width: "auto",
    },
    children: [
      {
        // Example of a "local" context just for one component
        contextId: "2context",
        type: 'SIMPLE',
        state: {
          myText: "Hello world"
        },
        actions: {
          updateMyText: "function(newText) { this.myText = newText; }"
        },
        children: [
          {
            id: 2,
            type: "p",
            style: {
              size: "24px",
              color: "rgba(255,0,0,1)",
            },
            children: [
              "${defaultContext.myText}"
            ]
          },
        ]
      },
      {
        id: 3,
        type: "div",
        style: {
          direction: "vertical",
          width: "24px"
        }
      },
      {
        // Example picking a specific context other than defaultContext.
        id: 4,
        type: "p",
        style: {
          size: "24px",
          color: "rgba(0,0,255, 1)"
        },
        children: [
          "${globalContext.count} Clicks"
        ]
      },
      {
        // Example where defaultContext has been overridden
        // Allows component to be more re-usable
        id: 5,
        type: "p",
        style: {
          size: "24px",
          color: "rgba(0,0,255, 1)"
        },
        defaultContext: "globalContext",
        children: [
          "${defaultContext.count} Clicks"
        ]
      }
    ]
  },
  {
    id: 6,
    type: "button",
    style: {
      size: "24px",
      backgroundColor: "rgba(0,0,0,1)",
      color: "white"
    },
    on: {
      click: `({ e, globalContext }) => {
        e.preventDefault();
        globalContext.actions.incrementCount();
      }`
    },
    children: [
      "Click Me!"
    ]
  }
]}]
monarchwadia commented 1 year ago

Love it. I think the simple context is good to ship as you described it in the JSON.

Props may still be necessary

  1. I feel props may still be necessary. Consider the case where I define a reusable component outside the hierarchy of the context. A <Button onClick={}> for example, or maybe a <Grid data={}/>. And now, I'm using it inside a div which has access to the context. How do we provide the parent context to the child component? In such a case, I feel prop references are necessary. Would you agree? Do you see a way out of this?

Sharing contexts

  1. Here, the context is strictly an ancestor of a component. What happens when I want to share context between components that don't share the same ancestry?

How about something like.... (based on your example)

type Component = any;
type Context = any;
type Data = {
  components: Component[];
  contexts: Context[];
}

/*

In creating the specification, we can liberally use normalized data structures to make the specification easier for framework code to work with.
This sidesteps the need to have a bunch of different data structures for specific and localized purposes. 
We can now have data structures that reference each other explicitly, which makes it easier to create tools that work with the specification.
*/

const sampleData: Data = {
  contexts: [
    {
      id: "globalContext",
      type: "SIMPLE",
      // this tells steelbit to make the context available to all children of the component that makes use of this context.
      // this gives us the ability to have a global context that is available to all components. 
      // it also gives us the ability to have a context that is only available to a specific component, but is shared with its children.
      cascade: true, 
      // this tells steelbit to treat this context as a template, based on which we can have multiple instances of the context.
      // It might be handy to create anonymous contexts based on templates.
      template: false, 
      // State is eval'd, so that the developer can define the state in a WYSIWYG editor.
      state: `{
        count: 0,
      }`,
      // Actions are eval'd, so that the developer can define the state in a WYSIWYG editor.
      actions: `{
        incrementCount: "({context}) => { context.count = context.count + 1; }",
      }`
    },

    // It might be handy to create anonymous contexts based on templates.
    // This is an example of an anonymous local context that instantiates a template context. For example, in a reusable component.
    // If there are 10 different components that use the same template context, then there will be 10 different local contexts.
    // This also gives us flexibility where we can have a single context that is shared between a few components.
    {
      id: "2context",
      type: "SIMPLE",
      cascade: false, // this means the context is only available to the component it is used on
      template: true, // this tells steelbit to treat this as a template. so, we can re-use this context in multiple components.
      state: {
        myText: "Hello world",
      },
      actions: {
        updateMyText: "({params, context}) => { context.myText = newText; }",
      },
    },

    // The following are instances of the context
    {
      id: "3tegohwln", // a randomly generated id
      parentId: "2context", // this tells steelbit to use the state and actions from 2context as the base for this context
    },
    {
      id: "135qrtegsdf", // a randomly generated id
      parentId: "2context", // this tells steelbit to use the state and actions from 2context as the base for this context
    },
    // The following context is actually never used, so it should show an error in the editor OR it should be removed by the editor when the user saves the app.
    {
      id: "64tserdxs", // a randomly generated id
      parentId: "2context", // this tells steelbit to use the state and actions from 2context as the base for this context
    },

  ],
  components: [
    {
      id: 113466,
      type: "div",
      // here we're including globalContext. since it's the root component and globalContext's `cascade` is set to `true`, globalContext will be available to all children
      includeContexts: ["globalContext"], 
      style: {
        direction: "horizontal",
        width: "auto",
      },
      children: [
        // counter
        {
          id: 21346143,
          type: "p",
          style: {
            color: "rgba(255,0,0,1)",
          },
          children: [
            {
              id: 813461,
              type: "p",
              children: ["${globalContext.count}"],
            },
            {
              id: 134616,
              type: "button",
              style: {
                width: "24px",
                color: "rgba(0,0,255, 1)",
              },
              children: ["Click me"],
              on: {
                click: `
                  ({globalContext}) => {
                    globalContext.incrementCount();
                  }
                `
              }
            }
          ],
        },
        {
          id: 31346,
          type: "div",
          style: {
            direction: "vertical",
            width: "24px",
          },
          includeContexts: ["3tegohwln"],
          children: [
            // a p tag that shows the context's state
            {
              id: 71436134,
              type: "p",
              // use 2context instead of 3tegohwln, for ergonomics
              children: ["${2context.myText}"], 
            },
            // a button that updates the context's state
            {
              id: 235325,
              type: "button",
              style: {
                width: "24px",
                color: "rgba(0,0,255, 1)",
              },
              children: ["Update text"],
              on: {
                // use 2context instead of 3tegohwln, for ergonomics
                click: `
                  ({2context}) => {
                    2context.updateMyText({newText: "Hello world 2"});
                  }`
              }
            },
          ]
        },
        {
          id: 130948,
          type: "div",
          style: {
            direction: "vertical",
            width: "24px",
          },
          // different context
          includeContexts: ["135qrtegsdf"],
          children: [
            // a p tag that shows the context's state
            {
              id: 14362,
              type: "p",
              // use 2context instead of 135qrtegsdf, for ergonomics
              children: ["${2context.myText}"], 
            },
            // a button that updates the context's state
            {
              id: 91346,
              type: "button",
              style: {
                width: "24px",
                color: "rgba(0,0,255, 1)",
              },
              children: ["Update text"],
              on: {
                // use 2context instead of 135qrtegsdf, for ergonomics
                click: `
                  ({2context}) => {
                    2context.updateMyText({newText: "Hello world 2"});
                  }`
              }
            },
          ]
        },
      ],
    },
  ]
};

Should we get on a call?

pldilley commented 1 year ago

Yes let's do a call!

I like this defining the contexts separately a bit more because it's easier to parse. We should create certain conventions on how these are used so that it's not allowed to become a messy web of context. For example, should we allow the sharing of a context between siblings or insist it be on the parent?

  1. Props: Maybe! Obviously it's necessary to expose the event handlers and element attributes. These can be defined on the component. As for developer defined props, it may not be necessary. I envision a separately defined component that assumes it will receive an appropriate context which provides everything needed. Example:
CONTEXT:
    {
      id: "mySpecificComponentStore",
      type: "SIMPLE",
      cascade: false,  // Make this a local store
      template: true, // Make this generate per component
      // State VALUES eval'd, so that the developer can define the state in a WYSIWYG editor.
      state: {
       "text": "${globalContext.initialText}"
      },
      actions: {
        "updateMyText": `({ self, newText }) => ({ ...self, text: newText })`
      }
    },

GENERIC COMPONENT:

    {
      name: 'MyCustomComponent',
      type: "div",
      style: {
        direction: "horizontal",
        width: "auto",
      },
      children: [
        // store could refer to the current state of all contexts included or some sensible name
        "${store.text}"
     ],
     on: {
       click: `
          ({ store }) => {
            store.actions.updateMyText({newText: "Hello world 2"});
          }`
      }
  }

USAGE:

    {
      id: '494858
      type: "@MyCustomComponent",
      includeContexts: ["mySpecificComponentStore"]
  }

To ensure a compatible context is passed, we could either find a way to leverage Typescript (how?), or define "context guards" that require passed contexts to have the specified properties with the specified types.

    {
      name: 'MyCustomComponent',
      type: "div",
      contextGuard: {
        "state.text": "String",
        "actions.updateMyText": "(newText: String): void"
      },
      dependenciesGuard: {
        "logger": "web-logger"
      },
      style: {
        direction: "horizontal",
        width: "auto",
      },
      children: [
        // store could refer to the current state of all contexts included or some sensible name
        "${store.text}"
     ],
     on: {
       click: "
          ({ store, logger }) => {
            store.actions.updateMyText({newText: `Hello world 2`});
            logger(`new value: 'Hello world 2'`);
          }"
      }
  }

Finally, we may need a way to get info from an external dependency (something as basic as importing a json file of facts). So we may want to add a special action to contexts that calls itself on load to allow them to initialize

pldilley commented 1 year ago

P.S. I'd get rid of type: "SIMPLE", and shift the repeating responsibility to the component.

CONTEXT:

    {
      id: "mySpecificComponentStore",
      cascade: false,  // Make this a local store
      template: true, // Make this generate per component
      // When state is set to array, it creates an inner store for each item, which can then be accessed by array
      state: [
        { "text": "1" },
        { "text": "2" }
      ],
      actions: {
        "updateMyText": `({ self, newText }) => ({ ...self, text: newText })`
      }
    },

IMPL:

    {
      id: '494858
      type: "div",
      includeContexts: ["mySpecificComponentStore"],
      // Dynamic runtime children
      children: `({ store }) => store.map((innerStoreId, idx)=> ({
           {
             id: `494858_${idx}`
             type: "@MyCustomComponent",
             includeContexts: [innerStoreId]
           }
      })`

  }
monarchwadia commented 1 year ago

should we allow the sharing of a context between siblings or insist it be on the parent?

I think whatever is easier for us to build framework and tooling on top of.

I feel we should allow exactly one way of doing things.

I feel we should talk about all of this in today's call.

monarchwadia commented 1 year ago
children: `({ store }) => store.map((innerStoreId, idx)=> ({
           {
             id: `494858_${idx}`
             type: "@MyCustomComponent",
             includeContexts: [innerStoreId]
           }
      })`

Are we supporting dynamic child rendering in the first version of the framework?

monarchwadia commented 1 year ago
To ensure a compatible context is passed, we could either find a way to leverage Typescript (how?), or define "context guards" that require passed contexts to have the specified properties with the specified types.

Good question. I don't know. Maybe we should first build unchecked proptypes?

Based on your "contextGuard" and "dependenciesGuard" properties, it looks like this framework is already way more powerful than React when it comes to validations of datatypes and inputs.

monarchwadia commented 1 year ago

Another thought: should we leave contextGuard and dependenciesGuard to be defined later, once we have a small community of users who are using the framework regularly?