Knockout.js using foreach and $index with if binding

I’m trying to display the elements from an iterated list grouped inside divs 3 by 3. I’ve used the value of $index but I can’t figure out why is not looking right.

<div class="row" data-bind="foreach: displaySel">
     <div class="col-md-2">
          <!--ko if: $index() % 3 === 0-->
          <div data-bind="attr:{id: 'div_'+$index()}">
          <!--/ko -->
               <div data-bind="attr:{id: 'g_'+$data.hId}" style="position:relative;">
                    <div>other things here</div>
               </div>
          <!--ko if: ($index() % 3 === 2 || $index() === displaySel.length - 1)-->
          </div>
          <!--/ko -->
    </div>
</div>

The first div should open before the first element info and close after the third. The second div will open before the fourth and close after another three and so on till the list ends. What’s the best approach here?

Answer

You cannot (or should not, I forget) really use Knockout in an imperative way like that, where you manually do or do not generate opening/closing tags. Instead, you need to nest things properly.

Put differently, treat <!-- ko if: ... --> and <!-- /ko --> as proper close/end tags that always need to wrap around full elements.

Put even differently, you’re trying to do the equivalent of this:

<div>
  Some content
  <strong>
    <div>
  </strong>

      CONTENT

  <strong>
    </div>
  </strong>
</p>

Perhaps Knockout should’ve given you an error, but (I guess in the spirit of HTML) it tries to make the best of it.

So instead, you should do the “grouping” logic in your view model. Added benefit is that you could potentially unit test it too. Here’s an example:

const chunkSize = 3;

class RootVm {
  displaySel = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25];
  displaySelGroups = ko.computed(() =>
    // Chunk in groups of 3 based on:
    // https://stackoverflow.com/a/44687374/419956
    [...Array(Math.ceil(this.displaySel.length / chunkSize))].map(_ => this.displaySel.splice(0,chunkSize))
  );
}

ko.applyBindings(new RootVm());
.col-2 { background: silver; border: 1px solid black; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.0/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.0/css/bootstrap.min.css" integrity="sha512-P5MgMn1jBN01asBgU0z60Qk4QxiXo86+wlFahKrsQf37c9cro517WzVSPPV1tDKzhku2iJ2FVgL67wG03SGnNA==" crossorigin="anonymous" />

<div class="row" data-bind="foreach: displaySelGroups">
  <div class="col-2" data-bind="foreach: $data">
    <div data-bind="attr:{id: 'div_'+$index()}">
      <div data-bind="attr:{id: 'g_'+$data.hId}" style="position:relative;">
        <div data-bind="text: $data"></div>
      </div>
    </div>
  </div>
</div>