JSXtra
NOTE: JSXtra is still a work in progress and is not advised for use yet
What is JSXtra?
It's JSX, plus some extra 🌶️
JSXtra is a JavaScript framework that aims to give you the familiar developer experience of tools like React or Vue but with a tiny footprint (no virtual DOM) and a few special tricks up its sleeve like:
- Global State Management built in by default (and dead simple to use);
- Scoped CSS built in by default (also ridiculously simple to use);
- Predictable API for re-rendering;
Like any modern framework JSXtra is reactive by default and supports a declarative coding style. JSXtra is built on Vite so it supports super fast hot reloading and has a rich plugin ecosystem for you to explore.
Quick Start
To get started use the degit
command to scaffold a starter project. There are currently 2 starter projects to choose from: A minimal starter (no routing or global store) and a more comprehensive starter (including routing and a global store).
Minimal Starter
npx degit bloycey/jsxtra-starter-minimal [folder-name]
cd [folder-name]
npm install
npm run dev
Comprehensive Starter
npx degit bloycey/jsxtra-starter-with-routes [folder-name]
cd [folder-name]
npm install
npm run dev
The following commands will create a copy of the starter project in a folder name of your choice, navigate to that folder, install the required dependencies, and start the dev server.
Core Concepts
What do you get from the starter projects?
Using the starter projects automatically sets up a few important files for you. To properly understand JSXtra, it's valuable to know what these files are, and what they do!
vite.config.js
This file is required for JSXtra to work in Vite. There is plenty to configure using Vite but what we are concerned about is how JSX rendering is handled. If you take a look at vite.config.js
you'll notice that we are injecting an import into each JSX file. This is the custom JSX parser that is at the core of JSXtra.
import { defineConfig } from "vite";
export default defineConfig({
esbuild: {
jsxFactory: "h",
jsxFragment: "Fragment",
jsxInject: `import { h, Fragment } from 'jsxtra'`
}
});
main.js
The main.js
is the entry point for our app and where JSXtra is initialised using the jsxtraInit
function. This function sets up the necessary configuration for your app including the appContainer, the base component, routing options, and more. You can read more about this function in the configuration section below.
The most minimal jsxtraInit()
import { jsxtraInit } from "jsxtra";
import App from "./App";
jsxtraInit({
appContainer: document.querySelector("#app"),
baseComponent: App
});
Configuration
The main point for configuring your JSXtra app is through the jsxtraInit
function. This function takes a configuration object.
jsxtraInit({
stores: [],
appContainer: document.querySelector("#app"),
baseComponent: App,
router: {
}
});
NOTE: Your baseComponent should be a dumb component that acts as a wrapper for other components. Updating state or store values directly in the base component will not work.
Stores
If you're familiar with Redux or the Context API then you know what stores are. Stores in JSXtra are very simple.
1.Initialise a store
const globalStore = {
name: "globalStore",
store: {
storeKey: "Store something here!"
}
};
export default store;
2. Pass the store into the configuration object
import globalStore from "./stores/globalStore";
jsxtraInit({
stores: [globalStore]
});
You can (and should!) use multiples stores to help organise your data.
3. Use the store in a component
To get the store into the component you can either import it at the top of your file:
import { store } from 'jsxtra'
or you can access it from the HELPERS prop that is automatically passed to all components.
const Component = ({ HELPERS }) => {
const { store } = HELPERS;
};
Once you've got access to the store you can update it directly.
const updateStore = newValue => {
store.globalStore.storeKey = newValue;
};
return (
<>
<button onClick={() => updateStore("New Value")}>
Change Store Key
</button>
<p>{store.globalStore.storeKey}</p>
</>
);
You can update the store using direct assignment. In other words you need a value on the right hand side of an equals sign. If you've used Svelte before it's a similar syntax. Updating the store will immediately trigger re-renders in any component watching for that store value.
You can watch for a store change by using the watch
helper passed into each component. The watch
helper is a function that accepts an array of store keys that the component will watch and re-render on changes.
const MyComponent = ({ HELPERS }) => {
const { watch } = HELPERS;
watch(["globalStore.storeKey"])
return (
...component goes here
)
}
export default MyComponent;
State
State is where you store component data that might change and isn't needed outside of the component. State changes always trigger a rerender of the component so endeavour to keep your components as small and modular as possible.
To initialise state first pull the getState
function out of the component helpers.
const StateExample = ({ HELPERS }) => {
const { getState } = HELPERS;
const state = getState({ name: "Andrew" });
...
getState
takes a single parameter: an object representing the starting state. You can add as many state variables as you like to this object.
To use the state simply drop the state variable in your code <p>{state.name}</p>
.
To update the state you can modify the state object directly. E.g.
const updateName = newName => {
state.name = newName
}
OR
<button onClick={() => { state.name = "Julia" }}>Change name to Julia</button>
Events
NOTE: JSXtra currently only supports inline onClick and onSubmit events. This will be updated soon to support other common events
JSXtra uses a familiar pattern for binding events to the UI: inline-click handlers. At present you can utilise onClick
and onSubmit
in your JSX. These attributes expect a function: either already defined or declared inline.
<button onClick={() => console.log("Hello World")}>Say hello</button>
{...OR}
<button onClick={helloWorld}>Say Hello</button>
CSS
CSS in JSXtra is automatically scoped to the component by default. This is achieved without any libraries or fancy code by leveraging web components and the Shadow DOM.
CSS in JSXtra looks like this:
const ComponentWithStyles = () => {
return (
<div>
<h1>Big heading</h1>
<style>{`
h1 {
font-size: 80px;
}
`}</style>
</div>
);
};
As demonstrated in the example above you can use generic html selectors without risk on conflicting styles.
CSS Strategies
Scoped CSS is great, and helps developers sidestep the complexities of large CSS files with complex cascades. But when only using scoped CSS you miss some of the core benefits that traditional CSS files offer: Modular, repeatable, and powerful reusable patterns.
Since JSXtra uses the Shadow DOM to isolate styles, sharing styles isn't as simple as having a global stylesheet that components can inherit from since global stylesheets don't penetrate the shadow DOM. There are, however, a few useful ways to get around this restriction that allow you to have your cake and eat it too: scoped CSS + repeatable code chunks when you want them!
- Create your own mixins, functions, and variables in a style store.
A great strategy for managing CSS in JSXtra is to create a style store where you can access design tokens and frequently repeated code. Here's an example:
stores/styleStore.js
const BASE_SPACING_UNIT = 8;
export const styleStore = {
name: "styleStore",
store: {
mixins: {
h1: `
font-size: 80px;
color: red;
text-decoration: underline; `
},
functions: {
spacing: number => `${number * BASE_SPACING_UNIT}px`
}
}
};
In a component somewhere
<style>{`
h1 {
${store.styleStore.mixins.h1}
margin-bottom: ${store.styleStore.functions.spacing(4)}
}
`}</style>
- Leverage native CSS custom properties
CSS custom properties penetrate through the shadow DOM! This is very handy because it means you can declare a global CSS file and import it into your main.js
and then freely use CSS custom properties throughout your app.
In a global CSS file
:root {
--dark-grey: #222222;
--light-grey: #7f8c8d;
}
Once they're set up you can use them in your <style>
tags as expected: color: var(--light-grey);
.
Routing
One of the only package dependencies JSXtra has is for Universal Router. Universal router is framework agnostic and contains all the powerful features available in tools like React Router. JSXtra integrates nicely to allow you to use universal router in your project. To get started simply add the router config object to the entrypoint of your app (probably main.js
).
jsxtraInit({
router: {
routes: [
{ path: '', action: Home },
{ path: '/router', action: Router },
{ path: '(.*)', action: FourOhFour }],
persistState: false
}
})
The router configuration takes an array of routes, each with a path and an action. Most of the time the actions will be a JSXtra component, however you can also choose to output raw HTML or even an asyncronous function! This allows you to fetch data for your component at the route level, which might be desirable in some instances.
Linking to new pages is handled in the traditional way: Just use an a
tag: <a href="/my-page">Link to a page</a>
.
The persistState
boolean determines whether the state of components will be maintained when changing routes. Usually on a route change the state of components on the previous route are removed from memory for performance reasons. Setting persistState
to true removes this performance optimisation and retains state in memory in between routes.
Please refer to the Universal Router Docs for more information about the router.
Lifecycle Hooks
JSXtra provides 2 lifecycle hooks: onMount
and onUnmount
. Both of these hooks accept a function that will run on mount or unmount.
const LifecyleExample = ({ HELPERS }) => {
const { onMount, onUnmount } = HELPERS;
onMount(() => console.log('component mounted!'))
onUnmount(() => console.log('component unmounted!'))
}
Usually components will only mount once. Changes based on state will not trigger a re-mount. Depending on the architecture of your app some components might be destroyed and recreated when the global store changes. Using these hooks can help you debug re-rendering issue, initialise code, and clean up event listeners when a component unmounts.
How's it work under the hood?
Acknowledgements
To Dos
- Testing. I plan to do this using vitest.
- Event handlers (currently only support onClick and onSubmit)
- Actually put some design into the starters (currently completely 'dev designed')
- Error handling (Fragment error)
- Move "SCOPED" setting somewhere else.