State management in Svelte. Overlays and store.

State management in Svelte. Overlays and store.

Hi! I recently learnt Svelte and fell in love with it. So much, that I decided to move my service, Measureland, to SvelteKit. Today I want to share how I solved one of the challenges that I faced: managing overlays in the app.

How it was before.

There are about ten different modals or popups and a few states of sidebar plus ways to combine them. App was originally written in plain HTML, CSS and JavaScript, so it was as simple as calling openPopup(popupClassName) on click. openPopup function searched for the given class in DOM and added "active" class to it. Simple imperative implementation. I had almost 2k strings in my index.html file with all these popups there.

What we need now (challenge).

Svelte is declarative and component based. It means we can have a single popup component for all popups. It helps us to not repeat ourselves, but searching the DOM is not an option anymore. As well as adding a class to make modals visible. We need to do it in a declarative way. Means we need our app to know about every popup there is, so we can tell it: "hey, open this popup" - and it will. The biggest problem is that we can't call the popup1-component method from popup2-component directly. And especially if we need to call it from another component tree. (We technically can, but it's very complex to do and support).

How we do it.

Let's start by telling our app all about the popups. I created a file: overlayStateDefault.js - under constants in the lib folder (full version):


export const overlayStateDefault = {
	loginPopup: {
		isOpen: false,
		type: 'popup',
		data: {},
	},
	registerPopup: {
		isOpen: false,
		type: 'popup',
		data: {},
	},
	...
};
			
  • isOpen: tells our app when we it open
  • type: tells our app what type of overlay we want to open (and close other overlays with this type)
  • data: we will most definitely want to pass data to some of the popups (instead of using global state)

Let's create superb Svelte store to make use of it (full version):


export const overlayStateStore = writable(overlayStateDefault);
			

Good. Next step is to write a few functions to work with it in a declarative way. I have helpers.js file in lib/utilities, let see our functions (full version):


const closeOverlaysWithSameType = (overlayType, state) => {
    let isModalOpen = false;

    const keysArray = Object.keys(state);
    const length = keysArray.length;

	keysArray.forEach((item, i) => {
		const { type, isOpen } = state[keysArray[i]];
        if (overlayType === type && isOpen) {
            // mutation here should be faster and has no consequences
            state[keysArray[i]].isOpen = false;
        } else {
            // check if any of other modals are open
            if (isOpen)
                isModalOpen = true;
        }
	});

    return {
        newState: state,
        isModalOpen,
    };
}

const openAnotherOverlay = (overlayName = null, data = {}) => {
    try {
		overlayStateStore.update(state => {
	        const overlayType = state[overlayName]['type'];
	        const { newState } = closeOverlaysWithSameType(overlayType, state);
	        return ({ ...newState, [overlayName]: { ...newState[overlayName], isOpen: true, data } });
	    });
	} catch (e) {
		console.warn('Define popup in constatns/overlayStateDefault.js');
	}
}

const closeOverlay = (overlayType = null) => {
    if (overlayType) {
        overlayStateStore.update(state => {
            const { newState, isModalOpen } = closeOverlaysWithSameType(overlayType, state);

            if (!isModalOpen)
                appStateStore.update(state => ({ ...state, openModal: false }));

            return ({ ...newState });
        });
    } else {
        overlayStateStore.update(state => overlayStateDefault);
        appStateStore.update(state => ({ ...state, openModal: false }));
    }
}

const closeOverlays = () => closeOverlay();
			

A lot of code, but nothing scary. closeOverlaysWithSameType is a helper-helper function. We call it to close currently opened popup-type modals before we open a new one.

openAnotherOverlay changes isOpen property of the needed modal. closeOverlay changes all the isOpen properties for the needed type to false. closeOverlays reuses our overlayStateDefault object to return overlays to the default state (close all modals) saving some time and calculations.

We have everything ready, now, how to use it in the app? Reasonable question, let's go!

I have an Overlay.svelte component used in routes/index.svelte (full version). This component has a few key points:


const checkIsOpen = state => {
	let openOverlays = [];
	for (let [key, value] of Object.entries(state)) {
		const { isOpen, data, type } = value;
		if (isOpen)
			openOverlays.push({ key, data, type });
	}

	if (openOverlays.length >= 2 && openOverlays[0].type === openOverlays[1].type) {
		throw new Error("Can't open two or more modals at once");
	}

	if (openOverlays.length === 1 && openOverlays[0].key === 'commentsSidebar') {
		// close sidebar if only comments is opened (expected behaviour: user closes rating)
		openOverlays = [];
		closeOverlays();
	}

	return openOverlays;
}

const manageOverlay = ({ key, data, type }) => {
	if (type === 'sidebar') {
		sidebarName = key;
		sidebarData = data;
		sidebarActive = true;
		if (browser)
			document.body.classList.add('sidebar-open');
	} else if (type === 'popup') {
		popupName = key;
		popupData = data;
		popupActive = true;
	}
}

const manageOverlays = openOverlays => {
	// TODO: change document.body.classList when resolved:
	// https://github.com/sveltejs/svelte/issues/3105#issuecomment-622437031
	if (browser)
		document.body.classList.remove('sidebar-open');

	sidebarActive = false;
	popupActive = false;

	if (openOverlays.length === 0)
		return;

	openOverlays.forEach(manageOverlay);
}

$: dataOpen = checkIsOpen($overlayStateStore);
$: manageOverlays(dataOpen);
			

The last two strings are responsible for tracking all the changes in overlayStateStore (thanks to reactivity) and passing the data to needed components. For example, if some of popup-type instances is opened, manageOverlays function will get its name (key) and data and pass it to PopupLayer component (full version):


{#if popupActive}
    <PopupLayer { popupName } { popupData } />
{/if}
			

PopupLayer, on the other hand, has a dictionary with pairs: popupName - component and svelte:component construction, to load modals dynamically based on popupName passed from parent component:


<script>
	...
	const popupList = {
		loginPopup: LoginPopup,
		registerPopup: RegisterPopup,
		...
	};
	...
	$: Popup = popupList[popupName]['component'];
	...
</script>
<div class="rating" on:click|preventDefault={closePopups}>
    <svelte:component this={Popup} { popupData }/>
</div>
			

That's it. The post is already too long, so let's summarize:

Now we can open our login popup from any part of an app by calling openAnotherOverlay('loginPopup') - everything else is on the side of reactivity, our helper functions and a few components. You can find the full code in my repo (migration is not finished yet).

Feel free to contact me (RomanistHere@pm.me) about any questions, suggestions or improvements. We can also move all the structure to a separate module and publish it. I stream migration on Twitch - come learn together!

To main page