Globals

Globals demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.

App.tsx

import './App.css';

import { Global, Session, Theme, Viewport } from './Global';

export default () => (
  <Global>
    <div className="container">
      <h1>App-wide state</h1>
      <p>
        The app's top component owns three states and provides them with itself,
        so anything below reaches each by type.
      </p>
      <div className="cards">
        <Size />
        <Account />
        <Appearance />
      </div>
      <small>
        Each card calls <code>.get()</code> and subscribes to only the fields it
        reads - resize the window and just the first one moves.
      </small>
    </div>
  </Global>
);

const Size = () => {
  const { width, compact } = Viewport.get();

  return (
    <article className="card">
      <h2>Viewport</h2>
      <b>{width}px</b>
      <small>{compact ? 'compact layout' : 'wide layout'}</small>
    </article>
  );
};

const Account = () => {
  const { user, login, logout } = Session.get();

  return (
    <article className="card">
      <h2>Session</h2>
      <b>{user ?? 'signed out'}</b>
      {user ? (
        <button onClick={logout}>Log out</button>
      ) : (
        <button onClick={login}>Log in</button>
      )}
    </article>
  );
};

const Appearance = () => {
  const { dark, toggle } = Theme.get();

  return (
    <article className="card">
      <h2>Theme</h2>
      <b>{dark ? 'dark' : 'light'}</b>
      <button onClick={toggle}>Switch</button>
    </article>
  );
};

App.css

.cards {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s3);
  width: 100%;
}

.card {
  display: flex;
  flex: 1 1 7rem;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s2);
  padding: var(--s4);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  text-align: left;
}

.card h2 {
  margin: 0;
  font-size: var(--t-xs);
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}

.card b {
  font-family: var(--font-mono);
  font-size: var(--t-lg);
  color: var(--accent);
}

.card button {
  align-self: stretch;
  margin-top: auto;
  font-size: var(--t-sm);
}

Global.ts

import { Component, Model } from '@expressive/mvc';

export class Global extends Component {
  viewport = new Viewport();
  session = new Session();
  theme = new Theme();
}

export class Viewport extends Model {
  width = window.innerWidth;

  get compact() {
    return this.width < 600;
  }

  protected new() {
    const update = () => (this.width = window.innerWidth);

    window.addEventListener('resize', update);

    return () => window.removeEventListener('resize', update);
  }
}

export class Session extends Model {
  user: string | null = null;

  login() {
    this.user = 'Ada';
  }

  logout() {
    this.user = null;
  }
}

export class Theme extends Model {
  dark = prefersDark();

  toggle() {
    this.dark = !this.dark;
  }

  protected new() {
    return this.get(({ dark }) => {
      document.documentElement.dataset.theme = dark ? 'dark' : 'light';
    });
  }
}

// Read the theme the document already has, or the effect above paints
// light over a dark page on activation.
function prefersDark() {
  const { theme } = document.documentElement.dataset;

  if (theme) return theme === 'dark';

  return matchMedia('(prefers-color-scheme: dark)').matches;
}