Svelte: Reactive statement cleanup function, like useEffect return value in React

Created on 18 Aug 2020  路  11Comments  路  Source: sveltejs/svelte

Is your feature request related to a problem? Please describe.

I'm trying to port some code that uses React hooks to Svelte. React has useEffect, which can be used to run some code when its dependencies change. I think the Svelte equivalent would be reactive statements ($: { doStuff() }).

When using useEffect you can return a function, and that function will be called when dependencies change, or when the component using the useEffect is unmounted. I do not see an equivalent in Svelte.

Here are the relevant docs for useEffect: https://reactjs.org/docs/hooks-reference.html#cleaning-up-an-effect

Describe the solution you'd like

I think it might be beneficial for Svelte to allow for returning cleanup functions in reactive statements.
In order to allow for returning though I think it would need to be possible to give a function as a reactive statement. Not sure, I'm hoping something like this could be possible though.

// A prop. Some kind of track object that can have listeners.
export let track

// We want to stop all tracks when our stop event listener is called.
// When the track prop is changed to a different track, or when the component is unmounted
// we want to remove the listener.
// This must be a function so we can use a return statement. The function should be called as if it was a statement in the same place.
$: () => {
  let onStop = () => track.stopAllTracks()
  track.on('stop', onStop)
  return () => {
    track.off('stop', onStop)
  }
}

For reference in React this would look like:

useEffect(() => {
  let onStop = () => track.stopAllTracks()
  track.on('stop', onStop)
  return () => {
    track.off('stop', onStop)
  }
}, [track])

Describe alternatives you've considered

The closest I could come up with is this:

import { onDestroy } from 'svelte'

export let track

let cleanup

$: {
  if (cleanup) {
    cleanup()
  }
  const onStop = () => track.stopAllTracks()
  track.on('stop', onStop)
  cleanup = () => track.off('stop', onStop)
}

onDestroy(() => {
  if (cleanup) {
    cleanup()
  }
})

This is verbose compared to how useEffect works. As a React user this seems like a step backwards, feeling more like class components, lifecycle methods, instance variables, instead of clean like the hooks version.

This could be cleaned up a bit by initializing cleanup:

import { onDestroy } from 'svelte'

export let track

let cleanup = () => {}
$: {
  cleanup()
  const onStop = () => track.stopAllTracks()
  track.on('stop', onStop)
  cleanup = () => track.off('stop', onStop)
}
onDestroy(cleanup)

How important is this feature to you?

It's important to me because I'm trying to convert React code to Svelte code and there doesn't seem to be a clean translation of this common React feature (useEffect + cleanup function).

I believe many other users may come from the React ecosystem and encounter this issue.

Most helpful comment

Cleanup is also required when dependencies (props in the example) change. As things are I think there will be many cases where components do not reflect their props in Svelte code if people are just using the lifecycle methods for these kinds of things.

Svelte doesn't re-render, so you need to respond to component mount/dismount and prop changes separately as they are distinct concepts and never tied together, unlike in React.

I'm suggesting this is a problem generally. Users will not think of being out of sync with props when writing onMount. Often this might not be a big deal, but I don't think it's optimal.

I'm not suggesting that Svelte should re-render like React does, or have dependency arrays, I'm suggesting there should be a way to write lifecycle related code that also responds to changing props, like how useEffect works. I think how React handles this could be a good source of inspiration.

I think Svelte's automatic/compiled reactivity is great. I think it just needs a few changes, possibly non-breaking additions, to be as powerful as hooks, when it comes to abstracting lifecycle related logic, and making it easy to keep effects in sync with props.

This actually does almost exactly what I want, and could almost be used to replace onMount:

let effect
$: effect = useEffect(() => {
  const onStop = () => track.stopAll()
  track.on('stop', onStop)
  return () => {
    track.off('stop', onStop)
  }
})
$: $effect

If there was a version of that that waited for actual mounting, behaved exactly the same, but was slightly less verbose, that would be perfect, while working the same way as the rest of Svelte (no needless re-rendering, no dependency arrays).

React version of the last REPL, for comparison.

All 11 comments

There is a library which attempts to shim react hooks, maybe you can just use that
https://github.com/devongovett/svelte-hooks

The fact this library exists means it's possible to do something similar without having to add new features to the core IMO.

@dummdidumm Thanks for adding the link. I did take a look at that repo, but it doesn't look like it implements calling cleanup functions. I tried out the code with the REPL and it seems they are not supported.

I suspect that there is an easier way to do this in Svelte that might not be immediately obvious if you're coming from React. A custom store or an action might work well here.

@kevmodrome I actually had a similar hook that I was trying to convert to Svelte and using a store creating function in a reactive declaration worked perfectly.

React:

import { useState, useEffect } from 'react';
import { LocalAudioTrack, LocalVideoTrack, RemoteAudioTrack, RemoteVideoTrack } from 'twilio-video';

type TrackType = LocalAudioTrack | undefined;

export default function useIsTrackEnabled(track: TrackType) {
  const [isEnabled, setIsEnabled] = useState(track ? track.isEnabled : false);

  useEffect(() => {
    setIsEnabled(track ? track.isEnabled : false);

    if (track) {
      const setEnabled = () => setIsEnabled(true);
      const setDisabled = () => setIsEnabled(false);
      track.on('enabled', setEnabled);
      track.on('disabled', setDisabled);
      return () => {
        track.off('enabled', setEnabled);
        track.off('disabled', setDisabled);
      };
    }
  }, [track]);

  return isEnabled;
}

Svelte:

import type { LocalAudioTrack } from 'twilio-video'

type TrackType = LocalAudioTrack | undefined

export const useIsTrackEnabled = (track: TrackType) => ({
  subscribe: (onChange: (v: boolean) => void) => {
    onChange(track ? track.isEnabled : false)
    if (track) {
      const onEnabled = () => onChange(true)
      const onDisabled = () => onChange(false)
      track.on('enabled', onEnabled)
      track.on('disabled', onDisabled)
      return () => {
        track.off('enabled', onEnabled)
        track.off('disabled', onDisabled)
      }
    }
  },
})

Usage in Svelte:

  export let track

  // We get a new store when track is changed, cleanup is handled by store auto subscription when the isTrackEnabled store is used.
  $: isTrackEnabled = useIsTrackEnabled(track)

For cases where data is not being derived though, and we're using a reactive statement for side effects, this doesn't seem to work well. I would have to render something invisible to get auto subscription / auto unsubscription working.

@dummdidumm Thanks for adding the link. I did take a look at that repo, but it doesn't look like it implements calling cleanup functions. I tried out the code with the REPL and it seems they are not supported.

Maybe this is just not implemented yet and can be added.

One thing I considered was abusing a custom store for this kind of thing. I figured I would have to do something like render an invisible element, but just using a reactive statement with $ is enough. That actually does seem to work alright:

// Store is reactive because it should re-evaluate when track is changed.
let store
$: store = {
    // We're not trying to get a value out of this, so onChange is never called.
    subscribe(onChange) {
        const onStop = () => track.stopAll()
        track.on('stop', onStop)
        return () => {
            track.off('stop', onStop)
        }
    }
}

// This reactive statement is just used to have the store automatically subscribed and unsubscribed.
$: $store

Alternatively:

const useEffect = (subscribe) => ({ subscribe })

let effect
$: effect = useEffect(() => {
  const onStop = () => track.stopAll()
  track.on('stop', onStop)
  return () => {
    track.off('stop', onStop)
  }
})
$: $effect

I've been thinking more about this. I think Svelte has some things to learn from React hooks.

Svelte right now has a lot of opportunities to have component state become out of sync with props. The most basic example I can think of is a label like this:

<script>
    import { onMount } from 'svelte'

    export let text

    let element

    onMount(() => {
        console.log(text)
        element.innerText = text
    })
</script>

<h1>
    {text}
</h1>

<h1 bind:this={element} > </h1>

When the text prop is changed the onMount function will not be run again, so the state of the DOM will not be synchronized with props, which is probably not what a user of this component would expect. The new text will also not be logged.

Solving this sort of issue is an advantage of React hooks. This is discussed in React as a UI Runtime, you can search for "For example, this code is buggy:". It's explaining that if the dependency array is not correct and the effect is not re-run then it becomes out of sync.

If Svelte came up with some kind of hooks like API maybe it could solve both these issues at once.

Just use onDestroy to cleanup when a component dismounts? Your most recent example is expected to cause this issue, it isn't a bug; it is how it is designed to work.

I'm not sure I understand the problem, everything you are describing is already possible. If you want to perform some cleanup everytime a reactive declaration runs then add that cleanup before your side effect. If you want to cleanup when a component dismounts, use onDestroy. If you want to abstract all of this into a reusable thing, use a combination of stores and onDestroy. Svelte doesn't re-render, so you need to respond to component mount/dismount and prop changes separately as they are distinct concepts and never tied together, unlike in React.

While react hooks were one of the catalysts for v3 we don't agree with with the APIs or the model and won't be emulating it.

Cleanup is also required when dependencies (props in the example) change. As things are I think there will be many cases where components do not reflect their props in Svelte code if people are just using the lifecycle methods for these kinds of things.

Svelte doesn't re-render, so you need to respond to component mount/dismount and prop changes separately as they are distinct concepts and never tied together, unlike in React.

I'm suggesting this is a problem generally. Users will not think of being out of sync with props when writing onMount. Often this might not be a big deal, but I don't think it's optimal.

I'm not suggesting that Svelte should re-render like React does, or have dependency arrays, I'm suggesting there should be a way to write lifecycle related code that also responds to changing props, like how useEffect works. I think how React handles this could be a good source of inspiration.

I think Svelte's automatic/compiled reactivity is great. I think it just needs a few changes, possibly non-breaking additions, to be as powerful as hooks, when it comes to abstracting lifecycle related logic, and making it easy to keep effects in sync with props.

This actually does almost exactly what I want, and could almost be used to replace onMount:

let effect
$: effect = useEffect(() => {
  const onStop = () => track.stopAll()
  track.on('stop', onStop)
  return () => {
    track.off('stop', onStop)
  }
})
$: $effect

If there was a version of that that waited for actual mounting, behaved exactly the same, but was slightly less verbose, that would be perfect, while working the same way as the rest of Svelte (no needless re-rendering, no dependency arrays).

React version of the last REPL, for comparison.

I agree with @DylanVann. Svelte should implement an official way to take advantage of hooks.
There are some cases where having a react style hooks is actually pretty useful and allow more code reusability cross components

I came across this (via https://dylanvann.com/svelte-version-of-useeffect) while looking for a way to do cleanup (unsubscribe and resubscribe) in reaction to a changes to props in Svelte.


Maybe this is just not implemented yet and can be added.

@dummdidumm, @DylanVann: Indeed, it looks like svelte-hooks _did_ add support for clean-up functions to their useEffect in https://github.com/devongovett/svelte-hooks/commit/1d39d959f3e151031335775b9e02c85fd45c7a90! ... which is great, though @DylanVann's much simpler and zero-dependency version is even better in some ways.

@DylanVann, I updated your Svelte Hooks Testing Cleanup REPL to use that latest version and the clean-up seems to work now (let me know if not; I don't _completely_ understand your example).

I also created a more minimal example REPL based on your React sandbox.

I guess the workarounds kinda work, but I would rather see first-class support for this. The workarounds so far are too verbose and ugly, and (like @DylanVann says) don't feel like idiomatic Svelte.

Things that _users_ of a framework should be doing (like reacting to changes in props) should be encouraged by the framework by providing first-class support for them and making them super easy.

(BTW, being able to subscribe to an _expression_ like one of the ideas in #4079 could make this a _little_ more concise (eliminate 2 lines of boilerplate): $: $(useEffect(...)) ... though I kind of doubt that will get added.)


Personally, I think the solution @DylanVann suggested (allowing reactive statements to optionally providing a cleanup function) seems pretty reasonable:

$: () => {
  const onStop = () => track.stopAll()
  track.on('stop', onStop)
  return () => {
    track.off('stop', onStop)
  }
}

It would be pretty backwards compatible since most/all existing reactive statements _don't_ currently look (to the parser) like a function, since a function by itself (without invoking it or assigning it to a variable) would have no effect (no pun intended).

Was this page helpful?
0 / 5 - 0 ratings

Related issues

mmjmanders picture mmjmanders  路  3Comments

noypiscripter picture noypiscripter  路  3Comments

Rich-Harris picture Rich-Harris  路  3Comments

plumpNation picture plumpNation  路  3Comments

AntoninBeaufort picture AntoninBeaufort  路  3Comments