Search code examples
functional-programmingreasonbucklescript

Interval clearing itself in Reason


In Reason, what would be the most elegant way of having an interval that clears itself when some condition is satisfied? In JavaScript I could do:

var myInterval = setInterval(function () {
    // do some stuff
    if (fancyCondition) {
        clearInterval(myInterval);
    }
}, 1000);

In Reason, the best I've come up with so far is this:

let intervalIdRef = ref(None);
let clearInterval = () =>
    switch (intervalIdRef^) {
    | Some(intervalId) => Js.Global.clearInterval(intervalId)
    | None => ()
    };
let intervalId = Js.Global.setInterval(() => {
    /* do some stuff */
    fancyCondition ? clearInterval() : ();
}, 1000);
intervalIdRef := Some(intervalId);

Is there a way to avoid using a ref?


Solution

  • setInterval/clearInterval is inherently mutable, but even if it wasn't your fancyCondition would be anyway, so removing the one ref here wouldn't buy you a lot. I think even with the ref it could improved through encapsulation though, and depending slightly on your fancyCondition we should be able to get the same behavior in a purely functional way by using setTimeout instead of setInterval/clearInterval.

    First, let's make your example concrete by adding a counter, printing the count, and then clearing the interval when we reach count 5, so we have something to work with:

    let intervalIdRef = ref(None);
    let count = ref(0);
    
    let clearInterval = () =>
      switch (intervalIdRef^) {
        | Some(intervalId) => Js.Global.clearInterval(intervalId)
        | None => ()
      };
    
    let intervalId = Js.Global.setInterval(() => {
      if (count^ < 5) {
        Js.log2("tick", count^);
        count := count^ + 1;
      } else {
        Js.log("abort!");
        clearInterval();
      }
    }, 200);
    
    intervalIdRef := Some(intervalId);
    

    The first thing I think we should do is to encapsulate the timer state/handle by wrapping it in a function and pass clearInterval to the callback instead of having it as a separate function that we might call several times without knowing if it actually does anything:

    let setInterval = (timeout, action) => {
      let intervalIdRef = ref(None);
      let clear = () =>
        switch (intervalIdRef^) {
          | Some(intervalId) => Js.Global.clearInterval(intervalId)
          | None => ()
        };
    
      let intervalId = Js.Global.setInterval(() => action(~clear), timeout);
      intervalIdRef := Some(intervalId);
    };
    
    let count = ref(0);
    setInterval(200, (~clear) => {
      if (count^ < 5) {
        Js.log2("tick", count^);
        count := count^ + 1;
      } else {
        Js.log("abort!");
        clear();
      }
    });
    

    We've now gotten rid of the global timer handle, which I think answers your original question, but we're still stuck with count as global state. So let's get rid of that too:

    let rec setTimeout = (timeout, action, state) => {
      let continue = setTimeout(timeout, action);
      let _:Js.Global.timeoutId =
        Js.Global.setTimeout(() => action(~continue, state), timeout)
    };
    
    setTimeout(200, (~continue, count) => {
      if (count < 5) {
        Js.log2("tick", count);
        continue(count + 1);
      } else {
        Js.log("abort!");
      }
    }, 0);
    

    Here we've turned the problem upside down a bit. Instead of using setInterval and clearInterval and passing a clear function into our callback, we pass it a continue function that's called when we want to carry on instead of when we want to bail out. That allows us to pass state forward, and to change the state without using mutation and refs by using recursion instead. And it does so with less code. I think that'd qualify as pretty elegant, if not precisely what you asked for :)