picoapp

repository·master·Indexed 41 days ago

https://github.com/selfawarestudio/picoapp

A tiny, no-framework component toolkit (v3.6.2) that automatically instantiates JavaScript modules on specific DOM elements. Designed for adding interactivity to static or CMS-driven websites, it features a shared state model, an event system, and a plugin architecture to extend component context.

Tokens
3.3K
Snippets
11
Records
12
Agent score
50%

What's inside picoapp

  1. How state and events work in picoapp

    master

    picoapp uses a shared state model. You can define initial state when creating the app instance, and components interact with this state via the ctx (context) object.

    Initializing State

    Pass a state object as the second argument to the picoapp constructor:

    const app = picoapp({ button }, { count: 0 });

    Consuming State

    Use ctx.getState() inside a component to access the current state:

    export default component((node, ctx) => {
      const count = ctx.getState().count;
    });

    Updating State via Events

    To update state, use ctx.emit(eventName, payload).

    • If the payload is an object, it is merged into the global state.
    • If the payload is a function, it receives the current state and should return the new state object.
    // Emitting an object (merges into state)
    ctx.emit("increment", { count: ctx.getState().count + 1 });
    
    // Emitting a function (uses previous state)
    ctx.emit("increment", state => {
      return { count: state.count + 1 };
    });

    Listening to Events

    Use ctx.on(eventName, callback) to react to state changes. The callback receives the entire state object.

    Subscription patterns:

    • Multi-subscribe: ctx.on(["prop1", "prop2"], state => {})
    • Wildcard: ctx.on("*", state => {}) (fires on all updates)
    • Property keyed: ctx.on("someProp", ({ someProp }) => {}) (fires when someProp updates)

    Updating State without Events

    Use ctx.hydrate(newState) to update the state directly without triggering an event.

    ctx.hydrate({ count: 12 });
    export default component((node, ctx) => {
      ctx.on("increment", state => {
        node.innerHTML = `I've been clicked ${state.count} times`;
      });
    
      node.onclick = () => {
        ctx.emit("increment", { count: ctx.getState().count + 1 });
      };
    });
  2. How to extend components using plugins

    master

    You can extend the context (ctx) object passed to every component by using the app.use() method. Plugins are functions that return an object, which is then merged into the ctx of every component.

    Note: Plugin properties that conflict with picoapp's built-in context properties (like on, emit, getState) will be overridden by the built-in properties.

    Example: Creating a 'props' plugin This plugin reads a data-props attribute from the component's DOM node and makes it available as ctx.props.

    // Define the plugin
    app.use(node => {
      const props = JSON.parse(node.dataset.props || "{}");
      return { props };
    });
    
    // Use the plugin in a component
    export default component((node, ctx) => {
      const { images = [] } = ctx.props;
      console.log(`Preloading ${images.length} images...`);
    });
    app.use(node => {
      const props = JSON.parse(node.dataset.props || "{}");
      return { props };
    });
  3. How to unmount components and clean up resources

    master

    When using AJAX page transitions (like PJAX), you may need to destroy components to prevent memory leaks or stale event listeners.

    1. Define a cleanup function: Return a function from your component definition. This function is called when the component is unmounted.
    2. Call unmount: Call app.unmount() on your picoapp instance.

    Note: All evx event subscriptions within a component are automatically destroyed when the component is unmounted. If a component does not return a cleanup function, it will remain mounted even after app.unmount() is called (useful for persistent elements like global navigation).

    Example:

    import { component } from "picoapp";
    
    export default component((node, ctx) => {
      function handler(e) { /* ... */ }
      node.addEventListener("click", handler);
    
      // Return the cleanup function
      return node => {
        node.removeEventListener("click", handler);
      };
    });
    
    // Trigger unmounting
    app.unmount();
    // In your component
    export default component((node, ctx) => {
      // ... setup logic ...
      return node => {
        // ... cleanup logic ...
      };
    });
    
    // In your app controller
    app.unmount();
  4. How to use picoapp to bind components to the DOM

    master

    picoapp automatically instantiates JavaScript modules on specific DOM elements if they exist on the page.

    1. Define data attributes: Add a data-component attribute to your HTML nodes.
    2. Create a component: Use the component function from picoapp. The function receives the node and a ctx (context) object.
    3. Initialize the app: Import your components and create a picoapp instance.
    4. Mount: Call app.mount() to bind the components to the DOM.

    Example:

    HTML

    <button data-component="button">I've been clicked 0 times</button>

    JavaScript (button.js)

    import { component } from "picoapp";
    
    export default component((node, ctx) => {
      let count = 0;
    
      node.onclick = () => {
        node.innerHTML = `I've been clicked ${++count} times`;
      };
    });

    JavaScript (main.js)

    import { picoapp } from "picoapp";
    import button from "./button.js";
    
    const app = picoapp({ button });
    app.mount();
  5. Reference: picoapp instance methods and properties

    master

    The picoapp instance provides several methods to manage the application globally.

    Method/PropertyDescription
    app.mount([selectors])Binds components to the DOM. selectors can be an array of attribute names (e.g., ['data-component', 'data-util']) to scan for.
    app.unmount()Unmounts all unmountable components.
    app.add({ componentName: component })Adds new components to the app (useful for lazy loading).
    app.emit(eventName, payload)Emits a global event on the app's event bus.
    app.on(eventName, callback)Listens for a global event on the app's event bus.
    app.hydrate(state)Updates the global state directly.
    app.getState()Returns the current global state object.

    Protected Events:

    • mount: Fired after all components have mounted.
    • unmount: Fired after all unmountable components have unmounted.
    • error: Fired if a component throws an error.

    Error Handling Example:

    app.on("error", ({ error }) => {
      // handle error
    });
  6. Manage application state and events

    master

    The object returned by picoapp() provides methods to interact with the application's core logic:

    • on(event, callback): Subscribes to an application-wide event.
    • emit(event, data): Triggers an application-wide event with optional data.
    • getState(): Returns the current application state.
    • hydrate(data): Updates the application state with new data.

    These methods allow components to communicate with each other and react to state changes globally.

    const app = picoapp({ myComponent }, { count: 0 })
    
    // Listen for events
    app.on('increment', () => {
      console.log('Increment event triggered')
    })
    
    // Emit events
    app.emit('increment')
    
    // Access state
    console.log(app.getState().count)
    
    // Update state
    app.hydrate({ count: 1 })
  7. Define a component with component()

    master

    Use the component function to wrap a factory function that initializes a module. The factory function receives a node (the DOM element) and a ctx (the context containing state, events, and plugins).

    To ensure proper cleanup, the component should return an object containing an unmount function and a subs array. The subs array should store the return values of any event subscriptions made via ctx.on to allow the application to clean them up automatically during unmounting.

    import { component } from 'picoapp'
    
    export const myComponent = component((node, ctx) => {
      // ctx contains getState, emit, on, etc.
      const subs = []
    
      // Use ctx.on to subscribe to events
      const unsubscribe = ctx.on('some-event', () => {
        console.log('Event received on', node)
      })
      subs.push(unsubscribe)
    
      return {
        subs,
        unmount: (node) => {
          console.log('Cleaning up component on', node)
        }
      }
    })
  8. Unmount all components

    master

    The unmount() method performs a clean teardown of the application. It iterates through all currently mounted components in reverse order and:

    1. Calls the component's unmount(node) function.
    2. Executes all cleanup functions returned by ctx.on() (stored in the component's subs array).
    3. Removes the component from the internal cache.
    4. Emits an 'unmount' event.

    This is critical for preventing memory leaks and removing event listeners when the application or a section of the UI is destroyed.

    const app = picoapp({ myComponent }, { count: 0 })
    app.mount()
    
    // Later, clean up everything
    app.unmount()
  9. Extend the application with plugins and add components dynamically

    master

    You can extend a running picoapp instance using use() and add():

    • use(fn): Adds a plugin. A plugin is a function that receives (node, evx) and returns an object of extensions. These extensions are merged and passed into the ctx of every component initialized via mount().
    • add(index): Dynamically adds new components to the application's component registry. index must be an object mapping names to factory functions.

    Note: Plugins are applied during the mount() process.

    const app = picoapp({}, { count: 0 })
    
    // Add a plugin that provides a helper to all components
    app.use((node, evx) => ({
      helper: () => console.log('Hello from plugin')
    }))
    
    // Add a component dynamically
    app.add({ myNewComponent: (node, ctx) => ({ subs: [], unmount: () => {} }) })
    
    app.mount()
  10. Initialize an application with picoapp()

    master

    The picoapp function is the main entry point for creating an application instance. It manages the global state, event bus, and the lifecycle of all mounted components.

    Arguments:

    • components (Object): A map of component names to their factory functions.
    • initialState (Object): The starting state for the application.
    • plugins (Array): An array of plugin functions to be applied during mounting.

    Returns: An application object with methods for state management, event handling, and lifecycle control.

    import { picoapp } from 'picoapp'
    import { myComponent } from './myComponent'
    
    const app = picoapp(
      { myComponent }, // components
      { count: 0 },   // initialState
      []               // plugins
    )
  11. Mount components to the DOM using mount()

    master

    The mount(attrs) method scans the DOM for elements containing specific attributes and initializes the corresponding components.

    Arguments:

    • attrs (String|Array): The attribute used to identify component nodes. Defaults to 'data-component'. You can pass a single string or an array of strings.

    Behavior:

    1. It finds all elements with the specified attribute(s).
    2. It reads the attribute value, which should be a space-separated list of component names (e.g., data-component="comp1 comp2").
    3. It executes the component factory, passing the DOM node and the application context (state, events, and plugins).
    4. It emits a 'mount' event once all nodes are processed.
    5. It removes the attribute from the node after mounting to prevent double-binding.
    /* HTML */
    // <div data-component="myComponent"></div>
    
    /* JavaScript */
    const app = picoapp({ myComponent }, { count: 0 })
    app.mount() // Mounts components found in [data-component]
    
    // Or with a custom attribute
    // <div data-app="myComponent"></div>
    app.mount('data-app')